diff --git a/.coveragerc b/.coveragerc index 5ef7ece3bd8..27aed1e1009 100644 --- a/.coveragerc +++ b/.coveragerc @@ -67,9 +67,6 @@ omit = homeassistant/components/android_ip_webcam/switch.py homeassistant/components/anel_pwrctrl/switch.py homeassistant/components/anthemav/media_player.py - homeassistant/components/apcupsd/__init__.py - homeassistant/components/apcupsd/binary_sensor.py - homeassistant/components/apcupsd/sensor.py homeassistant/components/apple_tv/__init__.py homeassistant/components/apple_tv/browse_media.py homeassistant/components/apple_tv/media_player.py @@ -123,6 +120,7 @@ omit = homeassistant/components/blink/binary_sensor.py homeassistant/components/blink/camera.py homeassistant/components/blink/sensor.py + homeassistant/components/blink/switch.py homeassistant/components/blinksticklight/light.py homeassistant/components/blockchain/sensor.py homeassistant/components/bloomsky/* @@ -144,6 +142,7 @@ omit = homeassistant/components/braviatv/coordinator.py homeassistant/components/braviatv/media_player.py homeassistant/components/braviatv/remote.py + homeassistant/components/broadlink/climate.py homeassistant/components/broadlink/light.py homeassistant/components/broadlink/remote.py homeassistant/components/broadlink/switch.py @@ -216,9 +215,6 @@ omit = homeassistant/components/discogs/sensor.py homeassistant/components/discord/__init__.py homeassistant/components/discord/notify.py - homeassistant/components/discovergy/__init__.py - homeassistant/components/discovergy/sensor.py - homeassistant/components/discovergy/coordinator.py homeassistant/components/dlib_face_detect/image_processing.py homeassistant/components/dlib_face_identify/image_processing.py homeassistant/components/dlink/data.py @@ -338,7 +334,6 @@ omit = homeassistant/components/epson/__init__.py homeassistant/components/epson/media_player.py homeassistant/components/epsonworkforce/sensor.py - homeassistant/components/eq3btsmart/climate.py homeassistant/components/escea/__init__.py homeassistant/components/escea/climate.py homeassistant/components/escea/discovery.py @@ -369,7 +364,8 @@ omit = homeassistant/components/faa_delays/binary_sensor.py homeassistant/components/faa_delays/coordinator.py homeassistant/components/familyhub/camera.py - homeassistant/components/fastdotcom/* + homeassistant/components/fastdotcom/sensor.py + homeassistant/components/fastdotcom/__init__.py homeassistant/components/ffmpeg/camera.py homeassistant/components/fibaro/__init__.py homeassistant/components/fibaro/binary_sensor.py @@ -426,9 +422,7 @@ omit = homeassistant/components/foursquare/* homeassistant/components/free_mobile/notify.py homeassistant/components/freebox/camera.py - homeassistant/components/freebox/device_tracker.py homeassistant/components/freebox/home_base.py - homeassistant/components/freebox/router.py homeassistant/components/freebox/switch.py homeassistant/components/fritz/common.py homeassistant/components/fritz/device_tracker.py @@ -769,9 +763,6 @@ omit = homeassistant/components/mutesync/binary_sensor.py homeassistant/components/mvglive/sensor.py homeassistant/components/mycroft/* - homeassistant/components/myq/__init__.py - homeassistant/components/myq/cover.py - homeassistant/components/myq/light.py homeassistant/components/mysensors/__init__.py homeassistant/components/mysensors/climate.py homeassistant/components/mysensors/cover.py @@ -822,7 +813,6 @@ omit = homeassistant/components/nfandroidtv/__init__.py homeassistant/components/nfandroidtv/notify.py homeassistant/components/nibe_heatpump/__init__.py - homeassistant/components/nibe_heatpump/climate.py homeassistant/components/nibe_heatpump/binary_sensor.py homeassistant/components/nibe_heatpump/select.py homeassistant/components/nibe_heatpump/sensor.py @@ -837,6 +827,7 @@ omit = homeassistant/components/noaa_tides/sensor.py homeassistant/components/nobo_hub/__init__.py homeassistant/components/nobo_hub/climate.py + homeassistant/components/nobo_hub/select.py homeassistant/components/nobo_hub/sensor.py homeassistant/components/norway_air/air_quality.py homeassistant/components/notify_events/notify.py @@ -937,6 +928,9 @@ omit = homeassistant/components/panasonic_viera/media_player.py homeassistant/components/pandora/media_player.py homeassistant/components/pencom/switch.py + homeassistant/components/permobil/__init__.py + homeassistant/components/permobil/coordinator.py + homeassistant/components/permobil/sensor.py homeassistant/components/philips_js/__init__.py homeassistant/components/philips_js/light.py homeassistant/components/philips_js/media_player.py @@ -950,8 +944,6 @@ omit = homeassistant/components/pilight/light.py homeassistant/components/pilight/switch.py homeassistant/components/ping/__init__.py - homeassistant/components/ping/binary_sensor.py - homeassistant/components/ping/device_tracker.py homeassistant/components/ping/helpers.py homeassistant/components/pioneer/media_player.py homeassistant/components/plaato/__init__.py @@ -1069,6 +1061,7 @@ omit = homeassistant/components/roomba/sensor.py homeassistant/components/roomba/vacuum.py homeassistant/components/roon/__init__.py + homeassistant/components/roon/event.py homeassistant/components/roon/media_browser.py homeassistant/components/roon/media_player.py homeassistant/components/roon/server.py @@ -1132,10 +1125,7 @@ omit = homeassistant/components/sky_hub/* homeassistant/components/skybeacon/sensor.py homeassistant/components/skybell/__init__.py - homeassistant/components/skybell/binary_sensor.py homeassistant/components/skybell/camera.py - homeassistant/components/skybell/coordinator.py - homeassistant/components/skybell/entity.py homeassistant/components/skybell/light.py homeassistant/components/skybell/sensor.py homeassistant/components/skybell/switch.py @@ -1291,9 +1281,11 @@ omit = homeassistant/components/system_bridge/__init__.py homeassistant/components/system_bridge/binary_sensor.py homeassistant/components/system_bridge/coordinator.py + homeassistant/components/system_bridge/entity.py homeassistant/components/system_bridge/media_player.py homeassistant/components/system_bridge/notify.py homeassistant/components/system_bridge/sensor.py + homeassistant/components/system_bridge/update.py homeassistant/components/systemmonitor/sensor.py homeassistant/components/tado/__init__.py homeassistant/components/tado/binary_sensor.py @@ -1431,6 +1423,13 @@ omit = homeassistant/components/upnp/device.py homeassistant/components/upnp/sensor.py homeassistant/components/vasttrafik/sensor.py + homeassistant/components/v2c/__init__.py + homeassistant/components/v2c/binary_sensor.py + homeassistant/components/v2c/coordinator.py + homeassistant/components/v2c/entity.py + homeassistant/components/v2c/number.py + homeassistant/components/v2c/sensor.py + homeassistant/components/v2c/switch.py homeassistant/components/velbus/__init__.py homeassistant/components/velbus/binary_sensor.py homeassistant/components/velbus/button.py @@ -1467,6 +1466,7 @@ omit = homeassistant/components/vicare/button.py homeassistant/components/vicare/climate.py homeassistant/components/vicare/entity.py + homeassistant/components/vicare/number.py homeassistant/components/vicare/sensor.py homeassistant/components/vicare/utils.py homeassistant/components/vicare/water_heater.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 27e2d2e5ad0..44a81718e10 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -10,6 +10,8 @@ "customizations": { "vscode": { "extensions": [ + "charliermarsh.ruff", + "ms-python.pylint", "ms-python.vscode-pylance", "visualstudioexptteam.vscodeintellicode", "redhat.vscode-yaml", @@ -19,14 +21,6 @@ // Please keep this file in sync with settings in home-assistant/.vscode/settings.default.json "settings": { "python.pythonPath": "/usr/local/bin/python", - "python.linting.enabled": true, - "python.linting.pylintEnabled": true, - "python.formatting.blackPath": "/usr/local/bin/black", - "python.linting.pycodestylePath": "/usr/local/bin/pycodestyle", - "python.linting.pydocstylePath": "/usr/local/bin/pydocstyle", - "python.linting.mypyPath": "/usr/local/bin/mypy", - "python.linting.pylintPath": "/usr/local/bin/pylint", - "python.formatting.provider": "black", "python.testing.pytestArgs": ["--no-cov"], "editor.formatOnPaste": false, "editor.formatOnSave": true, @@ -45,7 +39,10 @@ "!include_dir_list scalar", "!include_dir_merge_list scalar", "!include_dir_merge_named scalar" - ] + ], + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + } } } } diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 4bc1442d9e9..d69b1ac0c7d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -60,7 +60,7 @@ - [ ] There is no commented out code in this PR. - [ ] I have followed the [development checklist][dev-checklist] - [ ] I have followed the [perfect PR recommendations][perfect-pr] -- [ ] The code has been formatted using Black (`black --fast homeassistant tests`) +- [ ] The code has been formatted using Ruff (`ruff format homeassistant tests`) - [ ] Tests have been added to verify that the new code works. If user exposed functionality or configuration variables are added/changed: diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index c73a7bac340..9d13c07301e 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -330,7 +330,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Install Cosign - uses: sigstore/cosign-installer@v3.1.2 + uses: sigstore/cosign-installer@v3.2.0 with: cosign-release: "v2.0.2" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7a5c3efd1cb..71030e50074 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -35,9 +35,8 @@ on: env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 - MYPY_CACHE_VERSION: 5 - BLACK_CACHE_VERSION: 1 - HA_SHORT_VERSION: "2023.11" + MYPY_CACHE_VERSION: 6 + HA_SHORT_VERSION: "2023.12" DEFAULT_PYTHON: "3.11" ALL_PYTHON_VERSIONS: "['3.11', '3.12']" # 10.3 is the oldest supported version @@ -58,7 +57,6 @@ env: POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']" PRE_COMMIT_CACHE: ~/.cache/pre-commit PIP_CACHE: /tmp/pip-cache - BLACK_CACHE: /tmp/black-cache SQLALCHEMY_WARN_20: 1 PYTHONASYNCIODEBUG: 1 HASS_CI: 1 @@ -261,8 +259,8 @@ jobs: . venv/bin/activate pre-commit install-hooks - lint-black: - name: Check black + lint-ruff-format: + name: Check ruff-format runs-on: ubuntu-22.04 needs: - info @@ -276,13 +274,6 @@ jobs: with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true - - name: Generate partial black restore key - id: generate-black-key - run: | - black_version=$(cat requirements_test_pre_commit.txt | grep black | cut -d '=' -f 3) - echo "version=$black_version" >> $GITHUB_OUTPUT - echo "key=black-${{ env.BLACK_CACHE_VERSION }}-$black_version-${{ - 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/restore@v3.3.2 @@ -301,33 +292,12 @@ jobs: key: >- ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.pre-commit_cache_key }} - - name: Restore black cache - uses: actions/cache@v3.3.2 - with: - path: ${{ env.BLACK_CACHE }} - key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ - steps.generate-black-key.outputs.key }} - restore-keys: | - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-black-${{ - env.BLACK_CACHE_VERSION }}-${{ steps.generate-black-key.outputs.version }}-${{ - env.HA_SHORT_VERSION }}- - - name: Run black (fully) - if: needs.info.outputs.test_full_suite == 'true' - env: - BLACK_CACHE_DIR: ${{ env.BLACK_CACHE }} + - name: Run ruff-format run: | . venv/bin/activate - pre-commit run --hook-stage manual black --all-files --show-diff-on-failure - - name: Run black (partially) - if: needs.info.outputs.test_full_suite == 'false' - shell: bash + pre-commit run --hook-stage manual ruff-format --all-files --show-diff-on-failure env: - BLACK_CACHE_DIR: ${{ env.BLACK_CACHE }} - run: | - . venv/bin/activate - shopt -s globstar - pre-commit run --hook-stage manual black --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*} --show-diff-on-failure + RUFF_OUTPUT_FORMAT: github lint-ruff: name: Check ruff @@ -362,22 +332,12 @@ jobs: key: >- ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.pre-commit_cache_key }} - - name: Register ruff problem matcher - run: | - echo "::add-matcher::.github/workflows/matchers/ruff.json" - - name: Run ruff (fully) - if: needs.info.outputs.test_full_suite == 'true' + - name: Run ruff run: | . venv/bin/activate pre-commit run --hook-stage manual ruff --all-files --show-diff-on-failure - - name: Run ruff (partially) - if: needs.info.outputs.test_full_suite == 'false' - shell: bash - run: | - . venv/bin/activate - shopt -s globstar - pre-commit run --hook-stage manual ruff --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*} --show-diff-on-failure - + env: + RUFF_OUTPUT_FORMAT: github lint-other: name: Check other linters runs-on: ubuntu-22.04 @@ -787,7 +747,7 @@ jobs: cov_params+=(--cov-report=xml) fi - python3 -X dev -m pytest \ + python3 -b -X dev -m pytest \ -qq \ --timeout=9 \ --durations=10 \ @@ -824,7 +784,7 @@ jobs: cov_params+=(--cov-report=term-missing) fi - python3 -X dev -m pytest \ + python3 -b -X dev -m pytest \ -qq \ --timeout=9 \ -n auto \ @@ -945,7 +905,7 @@ jobs: cov_params+=(--cov-report=term-missing) fi - python3 -X dev -m pytest \ + python3 -b -X dev -m pytest \ -qq \ --timeout=20 \ -n 1 \ @@ -1069,7 +1029,7 @@ jobs: cov_params+=(--cov-report=term-missing) fi - python3 -X dev -m pytest \ + python3 -b -X dev -m pytest \ -qq \ --timeout=9 \ -n 1 \ diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index da7021e9df3..e7d9d4cd901 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,11 +29,11 @@ jobs: uses: actions/checkout@v4.1.1 - name: Initialize CodeQL - uses: github/codeql-action/init@v2.22.4 + uses: github/codeql-action/init@v2.22.8 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2.22.4 + uses: github/codeql-action/analyze@v2.22.8 with: category: "/language:python" diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 2b5364fa950..fb5deb2958f 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -10,7 +10,7 @@ jobs: if: github.repository_owner == 'home-assistant' runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v4.0.1 + - uses: dessant/lock-threads@v5.0.1 with: github-token: ${{ github.token }} issue-inactive-days: "30" diff --git a/.github/workflows/matchers/ruff.json b/.github/workflows/matchers/ruff.json deleted file mode 100644 index d189a3656a5..00000000000 --- a/.github/workflows/matchers/ruff.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "problemMatcher": [ - { - "owner": "ruff-error", - "severity": "error", - "pattern": [ - { - "regexp": "^(.*):(\\d+):(\\d+):\\s([EF]\\d{3}\\s.*)$", - "file": 1, - "line": 2, - "column": 3, - "message": 4 - } - ] - }, - { - "owner": "ruff-warning", - "severity": "warning", - "pattern": [ - { - "regexp": "^(.*):(\\d+):(\\d+):\\s([CDNW]\\d{3}\\s.*)$", - "file": 1, - "line": 2, - "column": 3, - "message": 4 - } - ] - } - ] -} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d9cca711131..ae135f30407 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,16 +1,11 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.1 + rev: v0.1.6 hooks: - id: ruff args: - --fix - - repo: https://github.com/psf/black-pre-commit-mirror - rev: 23.10.0 - hooks: - - id: black - args: - - --quiet + - id: ruff-format files: ^((homeassistant|pylint|script|tests)/.+)?[^/]+\.py$ - repo: https://github.com/codespell-project/codespell rev: v2.2.2 @@ -39,7 +34,7 @@ repos: hooks: - id: yamllint - repo: https://github.com/pre-commit/mirrors-prettier - rev: v2.7.1 + rev: v3.0.3 hooks: - id: prettier - repo: https://github.com/cdce8p/python-typing-update diff --git a/.prettierignore b/.prettierignore index 07637a380c5..b249b537137 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,4 @@ homeassistant/components/*/translations/*.json homeassistant/generated/* tests/components/lidarr/fixtures/initialize.js tests/components/lidarr/fixtures/initialize-wrong.js +tests/fixtures/core/config/yaml_errors/ diff --git a/.strict-typing b/.strict-typing index 1faf190a1de..3c18a1988f3 100644 --- a/.strict-typing +++ b/.strict-typing @@ -180,6 +180,7 @@ homeassistant.components.image_upload.* homeassistant.components.imap.* homeassistant.components.input_button.* homeassistant.components.input_select.* +homeassistant.components.input_text.* homeassistant.components.integration.* homeassistant.components.ipp.* homeassistant.components.iqvia.* @@ -201,6 +202,7 @@ homeassistant.components.ld2410_ble.* homeassistant.components.lidarr.* homeassistant.components.lifx.* homeassistant.components.light.* +homeassistant.components.linear_garage_door.* homeassistant.components.litejet.* homeassistant.components.litterrobot.* homeassistant.components.local_ip.* diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 951134133e5..8a5d7d486b7 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,7 @@ { - "recommendations": ["esbenp.prettier-vscode", "ms-python.python"] + "recommendations": [ + "charliermarsh.ruff", + "esbenp.prettier-vscode", + "ms-python.python" + ] } diff --git a/.vscode/launch.json b/.vscode/launch.json index c165e252b1a..78e0dda152b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -22,6 +22,14 @@ "args": ["--debug", "-c", "config", "--skip-pip"], "preLaunchTask": "Compile English translations" }, + { + "name": "Home Assistant: Changed tests", + "type": "python", + "request": "launch", + "module": "pytest", + "justMyCode": false, + "args": ["--timeout=10", "--picked"], + }, { // Debug by attaching to local Home Assistant server using Remote Python Debugger. // See https://www.home-assistant.io/integrations/debugpy/ diff --git a/.vscode/settings.default.json b/.vscode/settings.default.json index 3765d1251b8..e0792a360f1 100644 --- a/.vscode/settings.default.json +++ b/.vscode/settings.default.json @@ -1,6 +1,5 @@ { // Please keep this file in sync with settings in home-assistant/.devcontainer/devcontainer.json - "python.formatting.provider": "black", // Added --no-cov to work around TypeError: message must be set // https://github.com/microsoft/vscode-python/issues/14067 "python.testing.pytestArgs": ["--no-cov"], diff --git a/.yamllint b/.yamllint index e587d75d799..d8387c634ee 100644 --- a/.yamllint +++ b/.yamllint @@ -1,5 +1,6 @@ ignore: | azure-*.yml + tests/fixtures/core/config/yaml_errors/ rules: braces: level: error diff --git a/CODEOWNERS b/CODEOWNERS index b9cce3b9047..5ecb7d75cc8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -151,8 +151,8 @@ build.json @home-assistant/supervisor /homeassistant/components/bizkaibus/ @UgaitzEtxebarria /homeassistant/components/blebox/ @bbx-a @riokuu /tests/components/blebox/ @bbx-a @riokuu -/homeassistant/components/blink/ @fronzbot -/tests/components/blink/ @fronzbot +/homeassistant/components/blink/ @fronzbot @mkmer +/tests/components/blink/ @fronzbot @mkmer /homeassistant/components/bluemaestro/ @bdraco /tests/components/bluemaestro/ @bdraco /homeassistant/components/blueprint/ @home-assistant/core @@ -170,8 +170,8 @@ build.json @home-assistant/supervisor /tests/components/bosch_shc/ @tschamm /homeassistant/components/braviatv/ @bieniu @Drafteed /tests/components/braviatv/ @bieniu @Drafteed -/homeassistant/components/broadlink/ @danielhiversen @felipediel @L-I-Am -/tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am +/homeassistant/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger +/tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger /homeassistant/components/brother/ @bieniu /tests/components/brother/ @bieniu /homeassistant/components/brottsplatskartan/ @gjohansson-ST @@ -259,6 +259,8 @@ build.json @home-assistant/supervisor /tests/components/denonavr/ @ol-iver @starkillerOG /homeassistant/components/derivative/ @afaucogney /tests/components/derivative/ @afaucogney +/homeassistant/components/devialet/ @fwestenberg +/tests/components/devialet/ @fwestenberg /homeassistant/components/device_automation/ @home-assistant/core /tests/components/device_automation/ @home-assistant/core /homeassistant/components/device_tracker/ @home-assistant/core @@ -307,12 +309,12 @@ build.json @home-assistant/supervisor /tests/components/eafm/ @Jc2k /homeassistant/components/easyenergy/ @klaasnicolaas /tests/components/easyenergy/ @klaasnicolaas -/homeassistant/components/ecobee/ @marthoc @marcolivierarsenault -/tests/components/ecobee/ @marthoc @marcolivierarsenault +/homeassistant/components/ecobee/ @marcolivierarsenault +/tests/components/ecobee/ @marcolivierarsenault /homeassistant/components/ecoforest/ @pjanuario /tests/components/ecoforest/ @pjanuario -/homeassistant/components/econet/ @vangorra @w1ll1am23 -/tests/components/econet/ @vangorra @w1ll1am23 +/homeassistant/components/econet/ @w1ll1am23 +/tests/components/econet/ @w1ll1am23 /homeassistant/components/ecovacs/ @OverloadUT @mib1185 /homeassistant/components/ecowitt/ @pvizeli /tests/components/ecowitt/ @pvizeli @@ -345,17 +347,15 @@ build.json @home-assistant/supervisor /homeassistant/components/enigma2/ @fbradyirl /homeassistant/components/enocean/ @bdurrer /tests/components/enocean/ @bdurrer -/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @dgomes @joostlek -/tests/components/enphase_envoy/ @bdraco @cgarwood @dgomes @joostlek +/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @dgomes @joostlek @catsmanac +/tests/components/enphase_envoy/ @bdraco @cgarwood @dgomes @joostlek @catsmanac /homeassistant/components/entur_public_transport/ @hfurubotten /homeassistant/components/environment_canada/ @gwww @michaeldavie /tests/components/environment_canada/ @gwww @michaeldavie -/homeassistant/components/envisalink/ @ufodone /homeassistant/components/ephember/ @ttroy50 /homeassistant/components/epson/ @pszafer /tests/components/epson/ @pszafer /homeassistant/components/epsonworkforce/ @ThaStealth -/homeassistant/components/eq3btsmart/ @rytilahti /homeassistant/components/escea/ @lazdavila /tests/components/escea/ @lazdavila /homeassistant/components/esphome/ @OttoWinter @jesserockz @kbx81 @bdraco @@ -373,7 +373,8 @@ build.json @home-assistant/supervisor /tests/components/faa_delays/ @ntilley905 /homeassistant/components/fan/ @home-assistant/core /tests/components/fan/ @home-assistant/core -/homeassistant/components/fastdotcom/ @rohankapoorcom +/homeassistant/components/fastdotcom/ @rohankapoorcom @erwindouna +/tests/components/fastdotcom/ @rohankapoorcom @erwindouna /homeassistant/components/fibaro/ @rappenze /tests/components/fibaro/ @rappenze /homeassistant/components/file/ @fabaff @@ -490,8 +491,6 @@ build.json @home-assistant/supervisor /tests/components/greeneye_monitor/ @jkeljo /homeassistant/components/group/ @home-assistant/core /tests/components/group/ @home-assistant/core -/homeassistant/components/growatt_server/ @muppet3000 -/tests/components/growatt_server/ @muppet3000 /homeassistant/components/guardian/ @bachya /tests/components/guardian/ @bachya /homeassistant/components/habitica/ @ASMfreaK @leikoilja @@ -699,6 +698,8 @@ build.json @home-assistant/supervisor /tests/components/life360/ @pnbruckner /homeassistant/components/light/ @home-assistant/core /tests/components/light/ @home-assistant/core +/homeassistant/components/linear_garage_door/ @IceBotYT +/tests/components/linear_garage_door/ @IceBotYT /homeassistant/components/linux_battery/ @fabaff /homeassistant/components/litejet/ @joncar /tests/components/litejet/ @joncar @@ -811,8 +812,6 @@ build.json @home-assistant/supervisor /tests/components/mutesync/ @currentoor /homeassistant/components/my/ @home-assistant/core /tests/components/my/ @home-assistant/core -/homeassistant/components/myq/ @ehendrix23 @Lash-L -/tests/components/myq/ @ehendrix23 @Lash-L /homeassistant/components/mysensors/ @MartinHjelmare @functionpointer /tests/components/mysensors/ @MartinHjelmare @functionpointer /homeassistant/components/mystrom/ @fabaff @@ -929,6 +928,8 @@ build.json @home-assistant/supervisor /homeassistant/components/oru/ @bvlaicu /homeassistant/components/otbr/ @home-assistant/core /tests/components/otbr/ @home-assistant/core +/homeassistant/components/ourgroceries/ @OnFreund +/tests/components/ourgroceries/ @OnFreund /homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev /tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev /homeassistant/components/ovo_energy/ @timmo001 @@ -943,6 +944,8 @@ build.json @home-assistant/supervisor /tests/components/peco/ @IceBotYT /homeassistant/components/pegel_online/ @mib1185 /tests/components/pegel_online/ @mib1185 +/homeassistant/components/permobil/ @IsakNyberg +/tests/components/permobil/ @IsakNyberg /homeassistant/components/persistent_notification/ @home-assistant/core /tests/components/persistent_notification/ @home-assistant/core /homeassistant/components/philips_js/ @elupus @@ -979,6 +982,8 @@ build.json @home-assistant/supervisor /tests/components/prometheus/ @knyar /homeassistant/components/prosegur/ @dgomes /tests/components/prosegur/ @dgomes +/homeassistant/components/proximity/ @mib1185 +/tests/components/proximity/ @mib1185 /homeassistant/components/proxmoxve/ @jhollowe @Corbeno /homeassistant/components/prusalink/ @balloob /tests/components/prusalink/ @balloob @@ -1052,7 +1057,7 @@ build.json @home-assistant/supervisor /tests/components/reolink/ @starkillerOG /homeassistant/components/repairs/ @home-assistant/core /tests/components/repairs/ @home-assistant/core -/homeassistant/components/repetier/ @MTrab @ShadowBr0ther +/homeassistant/components/repetier/ @ShadowBr0ther /homeassistant/components/rflink/ @javicalle /tests/components/rflink/ @javicalle /homeassistant/components/rfxtrx/ @danielhiversen @elupus @RobBie1221 @@ -1061,6 +1066,8 @@ build.json @home-assistant/supervisor /tests/components/rhasspy/ @balloob @synesthesiam /homeassistant/components/ridwell/ @bachya /tests/components/ridwell/ @bachya +/homeassistant/components/ring/ @sdb9696 +/tests/components/ring/ @sdb9696 /homeassistant/components/risco/ @OnFreund /tests/components/risco/ @OnFreund /homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck @@ -1231,8 +1238,8 @@ build.json @home-assistant/supervisor /tests/components/stookwijzer/ @fwestenberg /homeassistant/components/stream/ @hunterjm @uvjustin @allenporter /tests/components/stream/ @hunterjm @uvjustin @allenporter -/homeassistant/components/stt/ @home-assistant/core @pvizeli -/tests/components/stt/ @home-assistant/core @pvizeli +/homeassistant/components/stt/ @home-assistant/core +/tests/components/stt/ @home-assistant/core /homeassistant/components/subaru/ @G-Two /tests/components/subaru/ @G-Two /homeassistant/components/suez_water/ @ooii @@ -1317,8 +1324,8 @@ build.json @home-assistant/supervisor /tests/components/tomorrowio/ @raman325 @lymanepp /homeassistant/components/totalconnect/ @austinmroczek /tests/components/totalconnect/ @austinmroczek -/homeassistant/components/tplink/ @rytilahti @thegardenmonkey -/tests/components/tplink/ @rytilahti @thegardenmonkey +/homeassistant/components/tplink/ @rytilahti @thegardenmonkey @bdraco +/tests/components/tplink/ @rytilahti @thegardenmonkey @bdraco /homeassistant/components/tplink_omada/ @MarkGodwin /tests/components/tplink_omada/ @MarkGodwin /homeassistant/components/traccar/ @ludeeus @@ -1339,8 +1346,8 @@ build.json @home-assistant/supervisor /tests/components/transmission/ @engrbm87 @JPHutchins /homeassistant/components/trend/ @jpbede /tests/components/trend/ @jpbede -/homeassistant/components/tts/ @home-assistant/core @pvizeli -/tests/components/tts/ @home-assistant/core @pvizeli +/homeassistant/components/tts/ @home-assistant/core +/tests/components/tts/ @home-assistant/core /homeassistant/components/tuya/ @Tuya @zlinoliver @frenck /tests/components/tuya/ @Tuya @zlinoliver @frenck /homeassistant/components/twentemilieu/ @frenck @@ -1375,6 +1382,8 @@ build.json @home-assistant/supervisor /tests/components/usgs_earthquakes_feed/ @exxamalte /homeassistant/components/utility_meter/ @dgomes /tests/components/utility_meter/ @dgomes +/homeassistant/components/v2c/ @dgomes +/tests/components/v2c/ @dgomes /homeassistant/components/vacuum/ @home-assistant/core /tests/components/vacuum/ @home-assistant/core /homeassistant/components/vallox/ @andre-richter @slovdahl @viiru- @@ -1384,13 +1393,13 @@ build.json @home-assistant/supervisor /homeassistant/components/velux/ @Julius2342 /homeassistant/components/venstar/ @garbled1 @jhollowe /tests/components/venstar/ @garbled1 @jhollowe -/homeassistant/components/verisure/ @frenck -/tests/components/verisure/ @frenck /homeassistant/components/versasense/ @imstevenxyz /homeassistant/components/version/ @ludeeus /tests/components/version/ @ludeeus /homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey /tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey +/homeassistant/components/vicare/ @CFenner +/tests/components/vicare/ @CFenner /homeassistant/components/vilfo/ @ManneW /tests/components/vilfo/ @ManneW /homeassistant/components/vivotek/ @HarlemSquirrel @@ -1501,8 +1510,8 @@ build.json @home-assistant/supervisor /tests/components/zerproc/ @emlove /homeassistant/components/zeversolar/ @kvanzuijlen /tests/components/zeversolar/ @kvanzuijlen -/homeassistant/components/zha/ @dmulcahey @adminiuga @puddly -/tests/components/zha/ @dmulcahey @adminiuga @puddly +/homeassistant/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES +/tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES /homeassistant/components/zodiac/ @JulienTant /tests/components/zodiac/ @JulienTant /homeassistant/components/zone/ @home-assistant/core diff --git a/Dockerfile b/Dockerfile index b61e1461c52..97eeb5b0dfa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,6 @@ +# Automatically generated by hassfest. +# +# To update, run python3 -m script.hassfest -p docker ARG BUILD_FROM FROM ${BUILD_FROM} diff --git a/Dockerfile.dev b/Dockerfile.dev index 857ccfa3997..a1143adde89 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -5,8 +5,7 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] # Uninstall pre-installed formatting and linting tools # They would conflict with our pinned versions RUN \ - pipx uninstall black \ - && pipx uninstall pydocstyle \ + pipx uninstall pydocstyle \ && pipx uninstall pycodestyle \ && pipx uninstall mypy \ && pipx uninstall pylint diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 2707f8b6899..000dde90faa 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -280,7 +280,8 @@ class AuthManager: credentials=credentials, name=info.name, is_active=info.is_active, - group_ids=[GROUP_ID_ADMIN], + group_ids=[GROUP_ID_ADMIN if info.group is None else info.group], + local_only=info.local_only, ) self.hass.bus.async_fire(EVENT_USER_ADDED, {"user_id": user.id}) diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index e604bf9d21c..32a700d65f9 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -134,3 +134,5 @@ class UserMeta(NamedTuple): name: str | None is_active: bool + group: str | None = None + local_only: bool | None = None diff --git a/homeassistant/auth/permissions/types.py b/homeassistant/auth/permissions/types.py index 0aa8807211a..cf3632d06d5 100644 --- a/homeassistant/auth/permissions/types.py +++ b/homeassistant/auth/permissions/types.py @@ -5,9 +5,7 @@ from collections.abc import Mapping ValueType = ( # Example: entities.all = { read: true, control: true } - Mapping[str, bool] - | bool - | None + Mapping[str, bool] | bool | None ) # Example: entities.domains = { light: … } diff --git a/homeassistant/auth/providers/command_line.py b/homeassistant/auth/providers/command_line.py index bfe8a2fdddb..4ec2ca18611 100644 --- a/homeassistant/auth/providers/command_line.py +++ b/homeassistant/auth/providers/command_line.py @@ -44,7 +44,11 @@ class CommandLineAuthProvider(AuthProvider): DEFAULT_TITLE = "Command Line Authentication" # which keys to accept from a program's stdout - ALLOWED_META_KEYS = ("name",) + ALLOWED_META_KEYS = ( + "name", + "group", + "local_only", + ) def __init__(self, *args: Any, **kwargs: Any) -> None: """Extend parent's __init__. @@ -118,10 +122,15 @@ class CommandLineAuthProvider(AuthProvider): ) -> UserMeta: """Return extra user metadata for credentials. - Currently, only name is supported. + Currently, supports name, group and local_only. """ meta = self._user_meta.get(credentials.data["username"], {}) - return UserMeta(name=meta.get("name"), is_active=True) + return UserMeta( + name=meta.get("name"), + is_active=True, + group=meta.get("group"), + local_only=meta.get("local_only") == "true", + ) class CommandLineLoginFlow(LoginFlow): diff --git a/homeassistant/auth/providers/legacy_api_password.py b/homeassistant/auth/providers/legacy_api_password.py index 0cadbf07589..98c246d74e4 100644 --- a/homeassistant/auth/providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -10,10 +10,11 @@ from typing import Any, cast import voluptuous as vol -from homeassistant.core import callback +from homeassistant.core import async_get_hass, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from ..models import Credentials, UserMeta from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow @@ -21,10 +22,28 @@ from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow AUTH_PROVIDER_TYPE = "legacy_api_password" CONF_API_PASSWORD = "api_password" -CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend( +_CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend( {vol.Required(CONF_API_PASSWORD): cv.string}, extra=vol.PREVENT_EXTRA ) + +def _create_repair_and_validate(config: dict[str, Any]) -> dict[str, Any]: + async_create_issue( + async_get_hass(), + "auth", + "deprecated_legacy_api_password", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_legacy_api_password", + ) + + return _CONFIG_SCHEMA(config) # type: ignore[no-any-return] + + +CONFIG_SCHEMA = _create_repair_and_validate + + LEGACY_USER_NAME = "Legacy API password user" diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py index 6962671cb2f..cc195c14c23 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -22,6 +22,7 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.network import is_cloud_connection from .. import InvalidAuthError from ..models import Credentials, RefreshToken, UserMeta @@ -192,11 +193,8 @@ class TrustedNetworksAuthProvider(AuthProvider): if any(ip_addr in trusted_proxy for trusted_proxy in self.trusted_proxies): raise InvalidAuthError("Can't allow access from a proxy server") - if "cloud" in self.hass.config.components: - from hass_nabucasa import remote # pylint: disable=import-outside-toplevel - - if remote.is_cloud_request.get(): - raise InvalidAuthError("Can't allow access from Home Assistant Cloud") + if is_cloud_connection(self.hass): + raise InvalidAuthError("Can't allow access from Home Assistant Cloud") @callback def async_validate_refresh_token( diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 098f970d55f..0998ac6274c 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -41,6 +41,7 @@ from .setup import ( DATA_SETUP, DATA_SETUP_STARTED, DATA_SETUP_TIME, + async_notify_setup_error, async_set_domains_to_be_loaded, async_setup_component, ) @@ -292,7 +293,8 @@ async def async_from_config_dict( try: await conf_util.async_process_ha_core_config(hass, core_config) except vol.Invalid as config_err: - conf_util.async_log_exception(config_err, "homeassistant", core_config, hass) + conf_util.async_log_schema_error(config_err, core.DOMAIN, core_config, hass) + async_notify_setup_error(hass, core.DOMAIN) return None except HomeAssistantError: _LOGGER.error( @@ -398,7 +400,7 @@ def async_enable_logging( logging.getLogger("httpx").setLevel(logging.WARNING) sys.excepthook = lambda *args: logging.getLogger(None).exception( - "Uncaught exception", exc_info=args # type: ignore[arg-type] + "Uncaught exception", exc_info=args ) threading.excepthook = lambda args: logging.getLogger(None).exception( "Uncaught thread exception", diff --git a/homeassistant/brands/eq3.json b/homeassistant/brands/eq3.json index 4052afac277..f5b1c8aeb87 100644 --- a/homeassistant/brands/eq3.json +++ b/homeassistant/brands/eq3.json @@ -1,5 +1,5 @@ { "domain": "eq3", "name": "eQ-3", - "integrations": ["eq3btsmart", "maxcube"] + "integrations": ["maxcube"] } diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index b74711ccbe6..2974c36607b 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["accuweather"], "quality_scale": "platinum", - "requirements": ["accuweather==2.1.0"] + "requirements": ["accuweather==2.1.1"] } diff --git a/homeassistant/components/adax/manifest.json b/homeassistant/components/adax/manifest.json index 65cffc509d5..2742180333b 100644 --- a/homeassistant/components/adax/manifest.json +++ b/homeassistant/components/adax/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/adax", "iot_class": "local_polling", "loggers": ["adax", "adax_local"], - "requirements": ["adax==0.3.0", "Adax-local==0.1.5"] + "requirements": ["adax==0.4.0", "Adax-local==0.1.5"] } diff --git a/homeassistant/components/adguard/manifest.json b/homeassistant/components/adguard/manifest.json index 24e1283e9df..52add51a663 100644 --- a/homeassistant/components/adguard/manifest.json +++ b/homeassistant/components/adguard/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["adguardhome"], - "requirements": ["adguardhome==0.6.2"] + "requirements": ["adguardhome==0.6.3"] } diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py index 9f1c0a5b0fe..523e1b73e16 100644 --- a/homeassistant/components/adguard/sensor.py +++ b/homeassistant/components/adguard/sensor.py @@ -22,20 +22,13 @@ SCAN_INTERVAL = timedelta(seconds=300) PARALLEL_UPDATES = 4 -@dataclass -class AdGuardHomeEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(kw_only=True) +class AdGuardHomeEntityDescription(SensorEntityDescription): + """Describes AdGuard Home sensor entity.""" value_fn: Callable[[AdGuardHome], Coroutine[Any, Any, int | float]] -@dataclass -class AdGuardHomeEntityDescription( - SensorEntityDescription, AdGuardHomeEntityDescriptionMixin -): - """Describes AdGuard Home sensor entity.""" - - SENSORS: tuple[AdGuardHomeEntityDescription, ...] = ( AdGuardHomeEntityDescription( key="dns_queries", diff --git a/homeassistant/components/adguard/strings.json b/homeassistant/components/adguard/strings.json index e34a7c88229..5b6a5a546f7 100644 --- a/homeassistant/components/adguard/strings.json +++ b/homeassistant/components/adguard/strings.json @@ -10,6 +10,9 @@ "username": "[%key:common::config_flow::data::username%]", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of the device running your AdGuard Home." } }, "hassio_confirm": { diff --git a/homeassistant/components/adguard/switch.py b/homeassistant/components/adguard/switch.py index 1020e8690f1..944a3c7b269 100644 --- a/homeassistant/components/adguard/switch.py +++ b/homeassistant/components/adguard/switch.py @@ -21,22 +21,15 @@ SCAN_INTERVAL = timedelta(seconds=10) PARALLEL_UPDATES = 1 -@dataclass -class AdGuardHomeSwitchEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(kw_only=True) +class AdGuardHomeSwitchEntityDescription(SwitchEntityDescription): + """Describes AdGuard Home switch entity.""" is_on_fn: Callable[[AdGuardHome], Callable[[], Coroutine[Any, Any, bool]]] turn_on_fn: Callable[[AdGuardHome], Callable[[], Coroutine[Any, Any, None]]] turn_off_fn: Callable[[AdGuardHome], Callable[[], Coroutine[Any, Any, None]]] -@dataclass -class AdGuardHomeSwitchEntityDescription( - SwitchEntityDescription, AdGuardHomeSwitchEntityDescriptionMixin -): - """Describes AdGuard Home switch entity.""" - - SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( AdGuardHomeSwitchEntityDescription( key="protection", diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index a4e0a1033ba..8244472f2b4 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -122,6 +122,13 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): if self._ac.get(ADVANTAGE_AIR_AUTOFAN): self._attr_fan_modes += [FAN_AUTO] + @property + def current_temperature(self) -> float | None: + """Return the selected zones current temperature.""" + if self._myzone: + return self._myzone["measuredTemp"] + return None + @property def target_temperature(self) -> float | None: """Return the current target temperature.""" diff --git a/homeassistant/components/aemet/__init__.py b/homeassistant/components/aemet/__init__.py index 13e636b2196..843693d2dc3 100644 --- a/homeassistant/components/aemet/__init__.py +++ b/homeassistant/components/aemet/__init__.py @@ -1,9 +1,8 @@ """The AEMET OpenData component.""" -import asyncio import logging -from aemet_opendata.exceptions import TownNotFound +from aemet_opendata.exceptions import AemetError, TownNotFound from aemet_opendata.interface import AEMET, ConnectionOptions from homeassistant.config_entries import ConfigEntry @@ -39,8 +38,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except TownNotFound as err: _LOGGER.error(err) return False - except asyncio.TimeoutError as err: - raise ConfigEntryNotReady("AEMET OpenData API timed out") from err + except AemetError as err: + raise ConfigEntryNotReady(err) from err weather_coordinator = WeatherUpdateCoordinator(hass, aemet) await weather_coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py index 7940ff92f72..c3328fc1b5d 100644 --- a/homeassistant/components/aemet/const.py +++ b/homeassistant/components/aemet/const.py @@ -12,6 +12,18 @@ from aemet_opendata.const import ( AOD_COND_RAINY, AOD_COND_SNOWY, AOD_COND_SUNNY, + AOD_CONDITION, + AOD_FORECAST_DAILY, + AOD_FORECAST_HOURLY, + AOD_PRECIPITATION, + AOD_PRECIPITATION_PROBABILITY, + AOD_TEMP, + AOD_TEMP_MAX, + AOD_TEMP_MIN, + AOD_TIMESTAMP, + AOD_WIND_DIRECTION, + AOD_WIND_SPEED, + AOD_WIND_SPEED_MAX, ) from homeassistant.components.weather import ( @@ -25,6 +37,15 @@ from homeassistant.components.weather import ( ATTR_CONDITION_RAINY, ATTR_CONDITION_SNOWY, ATTR_CONDITION_SUNNY, + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, + ATTR_FORECAST_NATIVE_WIND_SPEED, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, ) from homeassistant.const import Platform @@ -122,3 +143,30 @@ FORECAST_MODE_ATTR_API = { FORECAST_MODE_DAILY: ATTR_API_FORECAST_DAILY, FORECAST_MODE_HOURLY: ATTR_API_FORECAST_HOURLY, } + +FORECAST_MAP = { + AOD_FORECAST_DAILY: { + AOD_CONDITION: ATTR_FORECAST_CONDITION, + AOD_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY, + AOD_TEMP_MAX: ATTR_FORECAST_NATIVE_TEMP, + AOD_TEMP_MIN: ATTR_FORECAST_NATIVE_TEMP_LOW, + AOD_TIMESTAMP: ATTR_FORECAST_TIME, + AOD_WIND_DIRECTION: ATTR_FORECAST_WIND_BEARING, + AOD_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, + }, + AOD_FORECAST_HOURLY: { + AOD_CONDITION: ATTR_FORECAST_CONDITION, + AOD_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY, + AOD_PRECIPITATION: ATTR_FORECAST_NATIVE_PRECIPITATION, + AOD_TEMP: ATTR_FORECAST_NATIVE_TEMP, + AOD_TIMESTAMP: ATTR_FORECAST_TIME, + AOD_WIND_DIRECTION: ATTR_FORECAST_WIND_BEARING, + AOD_WIND_SPEED_MAX: ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, + AOD_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, + }, +} + +WEATHER_FORECAST_MODES = { + AOD_FORECAST_DAILY: "daily", + AOD_FORECAST_HOURLY: "hourly", +} diff --git a/homeassistant/components/aemet/entity.py b/homeassistant/components/aemet/entity.py new file mode 100644 index 00000000000..527ff046104 --- /dev/null +++ b/homeassistant/components/aemet/entity.py @@ -0,0 +1,23 @@ +"""Entity classes for the AEMET OpenData integration.""" +from __future__ import annotations + +from typing import Any + +from aemet_opendata.helpers import dict_nested_value + +from homeassistant.components.weather import Forecast +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .weather_update_coordinator import WeatherUpdateCoordinator + + +class AemetEntity(CoordinatorEntity[WeatherUpdateCoordinator]): + """Define an AEMET entity.""" + + def get_aemet_forecast(self, forecast_mode: str) -> list[Forecast]: + """Return AEMET entity forecast by mode.""" + return self.coordinator.data["forecast"][forecast_mode] + + def get_aemet_value(self, keys: list[str]) -> Any: + """Return AEMET entity value by keys.""" + return dict_nested_value(self.coordinator.data["lib"], keys) diff --git a/homeassistant/components/aemet/manifest.json b/homeassistant/components/aemet/manifest.json index 74d53cc117a..544931b50b5 100644 --- a/homeassistant/components/aemet/manifest.json +++ b/homeassistant/components/aemet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/aemet", "iot_class": "cloud_polling", "loggers": ["aemet_opendata"], - "requirements": ["AEMET-OpenData==0.4.5"] + "requirements": ["AEMET-OpenData==0.4.6"] } diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py index 03f91a74740..b7b3c31ab5b 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -1,16 +1,19 @@ """Support for the AEMET OpenData service.""" -from typing import cast + +from aemet_opendata.const import ( + AOD_CONDITION, + AOD_FORECAST_DAILY, + AOD_FORECAST_HOURLY, + AOD_HUMIDITY, + AOD_PRESSURE, + AOD_TEMP, + AOD_WEATHER, + AOD_WIND_DIRECTION, + AOD_WIND_SPEED, + AOD_WIND_SPEED_MAX, +) from homeassistant.components.weather import ( - ATTR_FORECAST_CONDITION, - ATTR_FORECAST_NATIVE_PRECIPITATION, - ATTR_FORECAST_NATIVE_TEMP, - ATTR_FORECAST_NATIVE_TEMP_LOW, - ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, - ATTR_FORECAST_NATIVE_WIND_SPEED, - ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TIME, - ATTR_FORECAST_WIND_BEARING, DOMAIN as WEATHER_DOMAIN, Forecast, SingleCoordinatorWeatherEntity, @@ -28,55 +31,16 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - ATTR_API_CONDITION, - ATTR_API_FORECAST_CONDITION, - ATTR_API_FORECAST_PRECIPITATION, - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_API_FORECAST_TEMP, - ATTR_API_FORECAST_TEMP_LOW, - ATTR_API_FORECAST_TIME, - ATTR_API_FORECAST_WIND_BEARING, - ATTR_API_FORECAST_WIND_MAX_SPEED, - ATTR_API_FORECAST_WIND_SPEED, - ATTR_API_HUMIDITY, - ATTR_API_PRESSURE, - ATTR_API_TEMPERATURE, - ATTR_API_WIND_BEARING, - ATTR_API_WIND_MAX_SPEED, - ATTR_API_WIND_SPEED, ATTRIBUTION, + CONDITIONS_MAP, DOMAIN, ENTRY_NAME, ENTRY_WEATHER_COORDINATOR, - FORECAST_MODE_ATTR_API, - FORECAST_MODE_DAILY, - FORECAST_MODE_HOURLY, - FORECAST_MODES, + WEATHER_FORECAST_MODES, ) +from .entity import AemetEntity from .weather_update_coordinator import WeatherUpdateCoordinator -FORECAST_MAP = { - FORECAST_MODE_DAILY: { - ATTR_API_FORECAST_CONDITION: ATTR_FORECAST_CONDITION, - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_API_FORECAST_TEMP_LOW: ATTR_FORECAST_NATIVE_TEMP_LOW, - ATTR_API_FORECAST_TEMP: ATTR_FORECAST_NATIVE_TEMP, - ATTR_API_FORECAST_TIME: ATTR_FORECAST_TIME, - ATTR_API_FORECAST_WIND_BEARING: ATTR_FORECAST_WIND_BEARING, - ATTR_API_FORECAST_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, - }, - FORECAST_MODE_HOURLY: { - ATTR_API_FORECAST_CONDITION: ATTR_FORECAST_CONDITION, - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_API_FORECAST_PRECIPITATION: ATTR_FORECAST_NATIVE_PRECIPITATION, - ATTR_API_FORECAST_TEMP: ATTR_FORECAST_NATIVE_TEMP, - ATTR_API_FORECAST_TIME: ATTR_FORECAST_TIME, - ATTR_API_FORECAST_WIND_BEARING: ATTR_FORECAST_WIND_BEARING, - ATTR_API_FORECAST_WIND_MAX_SPEED: ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, - ATTR_API_FORECAST_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, - }, -} - async def async_setup_entry( hass: HomeAssistant, @@ -95,11 +59,11 @@ async def async_setup_entry( if entity_registry.async_get_entity_id( WEATHER_DOMAIN, DOMAIN, - f"{config_entry.unique_id} {FORECAST_MODE_HOURLY}", + f"{config_entry.unique_id} {WEATHER_FORECAST_MODES[AOD_FORECAST_HOURLY]}", ): - for mode in FORECAST_MODES: - name = f"{domain_data[ENTRY_NAME]} {mode}" - unique_id = f"{config_entry.unique_id} {mode}" + for mode, mode_id in WEATHER_FORECAST_MODES.items(): + name = f"{domain_data[ENTRY_NAME]} {mode_id}" + unique_id = f"{config_entry.unique_id} {mode_id}" entities.append(AemetWeather(name, unique_id, weather_coordinator, mode)) else: entities.append( @@ -107,15 +71,18 @@ async def async_setup_entry( domain_data[ENTRY_NAME], config_entry.unique_id, weather_coordinator, - FORECAST_MODE_DAILY, + AOD_FORECAST_DAILY, ) ) async_add_entities(entities, False) -class AemetWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]): - """Implementation of an AEMET OpenData sensor.""" +class AemetWeather( + AemetEntity, + SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator], +): + """Implementation of an AEMET OpenData weather.""" _attr_attribution = ATTRIBUTION _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS @@ -137,7 +104,7 @@ class AemetWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]): super().__init__(coordinator) self._forecast_mode = forecast_mode self._attr_entity_registry_enabled_default = ( - self._forecast_mode == FORECAST_MODE_DAILY + self._forecast_mode == AOD_FORECAST_DAILY ) self._attr_name = name self._attr_unique_id = unique_id @@ -145,61 +112,50 @@ class AemetWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]): @property def condition(self): """Return the current condition.""" - return self.coordinator.data[ATTR_API_CONDITION] - - def _forecast(self, forecast_mode: str) -> list[Forecast]: - """Return the forecast array.""" - forecasts = self.coordinator.data[FORECAST_MODE_ATTR_API[forecast_mode]] - forecast_map = FORECAST_MAP[forecast_mode] - return cast( - list[Forecast], - [ - {ha_key: forecast[api_key] for api_key, ha_key in forecast_map.items()} - for forecast in forecasts - ], - ) + cond = self.get_aemet_value([AOD_WEATHER, AOD_CONDITION]) + return CONDITIONS_MAP.get(cond) @property def forecast(self) -> list[Forecast]: """Return the forecast array.""" - return self._forecast(self._forecast_mode) + return self.get_aemet_forecast(self._forecast_mode) @callback def _async_forecast_daily(self) -> list[Forecast]: """Return the daily forecast in native units.""" - return self._forecast(FORECAST_MODE_DAILY) + return self.get_aemet_forecast(AOD_FORECAST_DAILY) @callback def _async_forecast_hourly(self) -> list[Forecast]: """Return the hourly forecast in native units.""" - return self._forecast(FORECAST_MODE_HOURLY) + return self.get_aemet_forecast(AOD_FORECAST_HOURLY) @property def humidity(self): """Return the humidity.""" - return self.coordinator.data[ATTR_API_HUMIDITY] + return self.get_aemet_value([AOD_WEATHER, AOD_HUMIDITY]) @property def native_pressure(self): """Return the pressure.""" - return self.coordinator.data[ATTR_API_PRESSURE] + return self.get_aemet_value([AOD_WEATHER, AOD_PRESSURE]) @property def native_temperature(self): """Return the temperature.""" - return self.coordinator.data[ATTR_API_TEMPERATURE] + return self.get_aemet_value([AOD_WEATHER, AOD_TEMP]) @property def wind_bearing(self): """Return the wind bearing.""" - return self.coordinator.data[ATTR_API_WIND_BEARING] + return self.get_aemet_value([AOD_WEATHER, AOD_WIND_DIRECTION]) @property def native_wind_gust_speed(self): """Return the wind gust speed in native units.""" - return self.coordinator.data[ATTR_API_WIND_MAX_SPEED] + return self.get_aemet_value([AOD_WEATHER, AOD_WIND_SPEED_MAX]) @property def native_wind_speed(self): """Return the wind speed.""" - return self.coordinator.data[ATTR_API_WIND_SPEED] + return self.get_aemet_value([AOD_WEATHER, AOD_WIND_SPEED]) diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py index 01c2502fb37..cd95a8e0854 100644 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations from asyncio import timeout from datetime import timedelta import logging -from typing import Any, Final +from typing import Any, Final, cast from aemet_opendata.const import ( AEMET_ATTR_DATE, @@ -31,17 +31,24 @@ from aemet_opendata.const import ( AEMET_ATTR_TEMPERATURE, AEMET_ATTR_WIND, AEMET_ATTR_WIND_GUST, + AOD_CONDITION, + AOD_FORECAST, + AOD_FORECAST_DAILY, + AOD_FORECAST_HOURLY, + AOD_TOWN, ATTR_DATA, ) from aemet_opendata.exceptions import AemetError from aemet_opendata.forecast import ForecastValue from aemet_opendata.helpers import ( + dict_nested_value, get_forecast_day_value, get_forecast_hour_value, get_forecast_interval_value, ) from aemet_opendata.interface import AEMET +from homeassistant.components.weather import Forecast from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -79,6 +86,7 @@ from .const import ( ATTR_API_WIND_SPEED, CONDITIONS_MAP, DOMAIN, + FORECAST_MAP, ) _LOGGER = logging.getLogger(__name__) @@ -239,6 +247,12 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): weather_response, now ) + data = self.aemet.data() + forecasts: list[dict[str, Forecast]] = { + AOD_FORECAST_DAILY: self.aemet_forecast(data, AOD_FORECAST_DAILY), + AOD_FORECAST_HOURLY: self.aemet_forecast(data, AOD_FORECAST_HOURLY), + } + return { ATTR_API_CONDITION: condition, ATTR_API_FORECAST_DAILY: forecast_daily, @@ -261,8 +275,29 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): ATTR_API_WIND_BEARING: wind_bearing, ATTR_API_WIND_MAX_SPEED: wind_max_speed, ATTR_API_WIND_SPEED: wind_speed, + "forecast": forecasts, + "lib": data, } + def aemet_forecast( + self, + data: dict[str, Any], + forecast_mode: str, + ) -> list[Forecast]: + """Return the forecast array.""" + forecasts = dict_nested_value(data, [AOD_TOWN, forecast_mode, AOD_FORECAST]) + forecast_map = FORECAST_MAP[forecast_mode] + forecast_list: list[dict[str, Any]] = [] + for forecast in forecasts: + cur_forecast: dict[str, Any] = {} + for api_key, ha_key in forecast_map.items(): + value = forecast[api_key] + if api_key == AOD_CONDITION: + value = CONDITIONS_MAP.get(value) + cur_forecast[ha_key] = value + forecast_list += [cur_forecast] + return cast(list[Forecast], forecast_list) + def _get_daily_forecast_from_weather_response(self, weather_response, now): if weather_response.daily: parse = False diff --git a/homeassistant/components/agent_dvr/strings.json b/homeassistant/components/agent_dvr/strings.json index 77167b8294b..cbfc2e87a4d 100644 --- a/homeassistant/components/agent_dvr/strings.json +++ b/homeassistant/components/agent_dvr/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The IP address of the Agent DVR server." } } }, diff --git a/homeassistant/components/airnow/__init__.py b/homeassistant/components/airnow/__init__.py index 8fe2291d3b3..d7caaa120fc 100644 --- a/homeassistant/components/airnow/__init__.py +++ b/homeassistant/components/airnow/__init__.py @@ -11,6 +11,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -50,6 +51,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Clean up unused device entries with no entities + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry_id=entry.entry_id + ) + for dev in device_entries: + dev_entities = er.async_entries_for_device( + entity_registry, dev.id, include_disabled_entities=True + ) + if not dev_entities: + device_registry.async_remove_device(dev.id) + return True diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index f9d35d50810..c6ab27a8497 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -148,13 +148,14 @@ class AirNowSensor(CoordinatorEntity[AirNowDataUpdateCoordinator], SensorEntity) ) -> None: """Initialize.""" super().__init__(coordinator) + + _device_id = f"{coordinator.latitude}-{coordinator.longitude}" + self.entity_description = description - self._attr_unique_id = ( - f"{coordinator.latitude}-{coordinator.longitude}-{description.key.lower()}" - ) + self._attr_unique_id = f"{_device_id}-{description.key.lower()}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self._attr_unique_id)}, + identifiers={(DOMAIN, _device_id)}, manufacturer=DEFAULT_NAME, name=DEFAULT_NAME, ) diff --git a/homeassistant/components/airq/const.py b/homeassistant/components/airq/const.py index 82719515cbf..d1a2340b4bc 100644 --- a/homeassistant/components/airq/const.py +++ b/homeassistant/components/airq/const.py @@ -3,7 +3,6 @@ from typing import Final DOMAIN: Final = "airq" MANUFACTURER: Final = "CorantGmbH" -TARGET_ROUTE: Final = "average" CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³" ACTIVITY_BECQUEREL_PER_CUBIC_METER: Final = "Bq/m³" UPDATE_INTERVAL: float = 10.0 diff --git a/homeassistant/components/airq/coordinator.py b/homeassistant/components/airq/coordinator.py index 2d0d9d199df..76459005c45 100644 --- a/homeassistant/components/airq/coordinator.py +++ b/homeassistant/components/airq/coordinator.py @@ -13,7 +13,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN, MANUFACTURER, TARGET_ROUTE, UPDATE_INTERVAL +from .const import DOMAIN, MANUFACTURER, UPDATE_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -56,6 +56,4 @@ class AirQCoordinator(DataUpdateCoordinator): hw_version=info["hw_version"], ) ) - - data = await self.airq.get(TARGET_ROUTE) - return self.airq.drop_uncertainties_from_data(data) + return await self.airq.get_latest_data() diff --git a/homeassistant/components/airq/manifest.json b/homeassistant/components/airq/manifest.json index 97fb70c1b05..156f167913b 100644 --- a/homeassistant/components/airq/manifest.json +++ b/homeassistant/components/airq/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioairq"], - "requirements": ["aioairq==0.2.4"] + "requirements": ["aioairq==0.3.1"] } diff --git a/homeassistant/components/airtouch4/strings.json b/homeassistant/components/airtouch4/strings.json index 240b3e0007c..04c2e54cc7e 100644 --- a/homeassistant/components/airtouch4/strings.json +++ b/homeassistant/components/airtouch4/strings.json @@ -12,6 +12,9 @@ "title": "Set up your AirTouch 4 connection details.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your AirTouch controller." } } } diff --git a/homeassistant/components/airvisual_pro/strings.json b/homeassistant/components/airvisual_pro/strings.json index b5c68371fdf..641fa8963da 100644 --- a/homeassistant/components/airvisual_pro/strings.json +++ b/homeassistant/components/airvisual_pro/strings.json @@ -12,6 +12,9 @@ "data": { "ip_address": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "ip_address": "The hostname or IP address of your AirVisual Pro device." } } }, diff --git a/homeassistant/components/airzone/binary_sensor.py b/homeassistant/components/airzone/binary_sensor.py index a472a4991c6..cee0bb19691 100644 --- a/homeassistant/components/airzone/binary_sensor.py +++ b/homeassistant/components/airzone/binary_sensor.py @@ -9,7 +9,6 @@ from aioairzone.const import ( AZD_BATTERY_LOW, AZD_ERRORS, AZD_FLOOR_DEMAND, - AZD_NAME, AZD_PROBLEMS, AZD_SYSTEMS, AZD_ZONES, @@ -45,7 +44,6 @@ SYSTEM_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, .. device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, key=AZD_PROBLEMS, - name="Problem", ), ) @@ -53,17 +51,16 @@ ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...] AirzoneBinarySensorEntityDescription( device_class=BinarySensorDeviceClass.RUNNING, key=AZD_AIR_DEMAND, - name="Air Demand", + translation_key="air_demand", ), AirzoneBinarySensorEntityDescription( device_class=BinarySensorDeviceClass.BATTERY, key=AZD_BATTERY_LOW, - name="Battery Low", ), AirzoneBinarySensorEntityDescription( device_class=BinarySensorDeviceClass.RUNNING, key=AZD_FLOOR_DEMAND, - name="Floor Demand", + translation_key="floor_demand", ), AirzoneBinarySensorEntityDescription( attributes={ @@ -72,7 +69,6 @@ ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...] device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, key=AZD_PROBLEMS, - name="Problem", ), ) @@ -149,7 +145,6 @@ class AirzoneSystemBinarySensor(AirzoneSystemEntity, AirzoneBinarySensor): ) -> None: """Initialize.""" super().__init__(coordinator, entry, system_data) - self._attr_name = f"System {system_id} {description.name}" self._attr_unique_id = f"{self._attr_unique_id}_{system_id}_{description.key}" self.entity_description = description self._async_update_attrs() @@ -169,7 +164,6 @@ class AirzoneZoneBinarySensor(AirzoneZoneEntity, AirzoneBinarySensor): """Initialize.""" super().__init__(coordinator, entry, system_zone_id, zone_data) - self._attr_name = f"{zone_data[AZD_NAME]} {description.name}" self._attr_unique_id = ( f"{self._attr_unique_id}_{system_zone_id}_{description.key}" ) diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index c3ba74236bd..22172255b9b 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -19,7 +19,6 @@ from aioairzone.const import ( AZD_MASTER, AZD_MODE, AZD_MODES, - AZD_NAME, AZD_ON, AZD_SPEED, AZD_SPEEDS, @@ -32,6 +31,7 @@ from aioairzone.const import ( ) from homeassistant.components.climate import ( + ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, FAN_AUTO, @@ -114,6 +114,7 @@ async def async_setup_entry( class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): """Define an Airzone sensor.""" + _attr_name = None _speeds: dict[int, str] = {} _speeds_reverse: dict[str, int] = {} @@ -127,7 +128,6 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): """Initialize Airzone climate entity.""" super().__init__(coordinator, entry, system_zone_id, zone_data) - self._attr_name = f"{zone_data[AZD_NAME]}" self._attr_unique_id = f"{self._attr_unique_id}_{system_zone_id}" self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE self._attr_target_temperature_step = API_TEMPERATURE_STEP @@ -209,7 +209,9 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): await self._async_update_hvac_params(params) if slave_raise: - raise HomeAssistantError(f"Mode can't be changed on slave zone {self.name}") + raise HomeAssistantError( + f"Mode can't be changed on slave zone {self.entity_id}" + ) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -221,6 +223,9 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): params[API_HEAT_SET_POINT] = kwargs[ATTR_TARGET_TEMP_LOW] await self._async_update_hvac_params(params) + if ATTR_HVAC_MODE in kwargs: + await self.async_set_hvac_mode(kwargs[ATTR_HVAC_MODE]) + @callback def _handle_coordinator_update(self) -> None: """Update attributes when the coordinator updates.""" diff --git a/homeassistant/components/airzone/entity.py b/homeassistant/components/airzone/entity.py index 2310d5fb5a4..2c3dba472ef 100644 --- a/homeassistant/components/airzone/entity.py +++ b/homeassistant/components/airzone/entity.py @@ -39,6 +39,8 @@ _LOGGER = logging.getLogger(__name__) class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator]): """Define an Airzone entity.""" + _attr_has_entity_name = True + def get_airzone_value(self, key: str) -> Any: """Return Airzone entity value by key.""" raise NotImplementedError() @@ -62,7 +64,7 @@ class AirzoneSystemEntity(AirzoneEntity): identifiers={(DOMAIN, f"{entry.entry_id}_{self.system_id}")}, manufacturer=MANUFACTURER, model=self.get_airzone_value(AZD_MODEL), - name=self.get_airzone_value(AZD_FULL_NAME), + name=f"System {self.system_id}", sw_version=self.get_airzone_value(AZD_FIRMWARE), via_device=(DOMAIN, f"{entry.entry_id}_ws"), ) @@ -116,9 +118,7 @@ class AirzoneHotWaterEntity(AirzoneEntity): try: await self.coordinator.airzone.set_dhw_parameters(_params) except AirzoneError as error: - raise HomeAssistantError( - f"Failed to set dhw {self.name}: {error}" - ) from error + raise HomeAssistantError(f"Failed to set DHW: {error}") from error self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) @@ -172,7 +172,7 @@ class AirzoneZoneEntity(AirzoneEntity): identifiers={(DOMAIN, f"{entry.entry_id}_{system_zone_id}")}, manufacturer=MANUFACTURER, model=self.get_airzone_value(AZD_THERMOSTAT_MODEL), - name=f"Airzone [{system_zone_id}] {zone_data[AZD_NAME]}", + name=zone_data[AZD_NAME], sw_version=self.get_airzone_value(AZD_THERMOSTAT_FW), via_device=(DOMAIN, f"{entry.entry_id}_{self.system_id}"), ) @@ -203,7 +203,7 @@ class AirzoneZoneEntity(AirzoneEntity): await self.coordinator.airzone.set_hvac_parameters(_params) except AirzoneError as error: raise HomeAssistantError( - f"Failed to set zone {self.name}: {error}" + f"Failed to set zone {self.entity_id}: {error}" ) from error self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) diff --git a/homeassistant/components/airzone/select.py b/homeassistant/components/airzone/select.py index 1a0d577bb35..78b4dee3b72 100644 --- a/homeassistant/components/airzone/select.py +++ b/homeassistant/components/airzone/select.py @@ -11,7 +11,6 @@ from aioairzone.const import ( API_SLEEP, AZD_COLD_ANGLE, AZD_HEAT_ANGLE, - AZD_NAME, AZD_SLEEP, AZD_ZONES, ) @@ -60,7 +59,6 @@ ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = ( api_param=API_COLD_ANGLE, entity_category=EntityCategory.CONFIG, key=AZD_COLD_ANGLE, - name="Cold Angle", options=list(GRILLE_ANGLE_DICT), options_dict=GRILLE_ANGLE_DICT, translation_key="grille_angles", @@ -69,16 +67,14 @@ ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = ( api_param=API_HEAT_ANGLE, entity_category=EntityCategory.CONFIG, key=AZD_HEAT_ANGLE, - name="Heat Angle", options=list(GRILLE_ANGLE_DICT), options_dict=GRILLE_ANGLE_DICT, - translation_key="grille_angles", + translation_key="heat_angles", ), AirzoneSelectDescription( api_param=API_SLEEP, entity_category=EntityCategory.CONFIG, key=AZD_SLEEP, - name="Sleep", options=list(SLEEP_DICT), options_dict=SLEEP_DICT, translation_key="sleep_times", @@ -146,7 +142,6 @@ class AirzoneZoneSelect(AirzoneZoneEntity, AirzoneBaseSelect): """Initialize.""" super().__init__(coordinator, entry, system_zone_id, zone_data) - self._attr_name = f"{zone_data[AZD_NAME]} {description.name}" self._attr_unique_id = ( f"{self._attr_unique_id}_{system_zone_id}_{description.key}" ) diff --git a/homeassistant/components/airzone/sensor.py b/homeassistant/components/airzone/sensor.py index 1dd67294aff..c14eaf48ff1 100644 --- a/homeassistant/components/airzone/sensor.py +++ b/homeassistant/components/airzone/sensor.py @@ -6,7 +6,6 @@ from typing import Any, Final from aioairzone.const import ( AZD_HOT_WATER, AZD_HUMIDITY, - AZD_NAME, AZD_TEMP, AZD_TEMP_UNIT, AZD_WEBSERVER, @@ -54,7 +53,7 @@ WEBSERVER_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, key=AZD_WIFI_RSSI, - name="RSSI", + translation_key="rssi", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, state_class=SensorStateClass.MEASUREMENT, ), @@ -64,14 +63,12 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( SensorEntityDescription( device_class=SensorDeviceClass.TEMPERATURE, key=AZD_TEMP, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( device_class=SensorDeviceClass.HUMIDITY, key=AZD_HUMIDITY, - name="Humidity", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), @@ -144,8 +141,6 @@ class AirzoneSensor(AirzoneEntity, SensorEntity): class AirzoneHotWaterSensor(AirzoneHotWaterEntity, AirzoneSensor): """Define an Airzone Hot Water sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: AirzoneUpdateCoordinator, @@ -176,7 +171,6 @@ class AirzoneWebServerSensor(AirzoneWebServerEntity, AirzoneSensor): ) -> None: """Initialize.""" super().__init__(coordinator, entry) - self._attr_name = f"WebServer {description.name}" self._attr_unique_id = f"{self._attr_unique_id}_ws_{description.key}" self.entity_description = description self._async_update_attrs() @@ -196,7 +190,6 @@ class AirzoneZoneSensor(AirzoneZoneEntity, AirzoneSensor): """Initialize.""" super().__init__(coordinator, entry, system_zone_id, zone_data) - self._attr_name = f"{zone_data[AZD_NAME]} {description.name}" self._attr_unique_id = ( f"{self._attr_unique_id}_{system_zone_id}_{description.key}" ) diff --git a/homeassistant/components/airzone/strings.json b/homeassistant/components/airzone/strings.json index 037ebe52d78..438304d7f41 100644 --- a/homeassistant/components/airzone/strings.json +++ b/homeassistant/components/airzone/strings.json @@ -25,8 +25,17 @@ } }, "entity": { + "binary_sensor": { + "air_demand": { + "name": "Air demand" + }, + "floor_demand": { + "name": "Floor demand" + } + }, "select": { "grille_angles": { + "name": "Cold angle", "state": { "90deg": "90°", "50deg": "50°", @@ -34,7 +43,17 @@ "40deg": "40°" } }, + "heat_angles": { + "name": "Heat angle", + "state": { + "90deg": "[%key:component::airzone::entity::select::grille_angles::state::90deg%]", + "50deg": "[%key:component::airzone::entity::select::grille_angles::state::50deg%]", + "45deg": "[%key:component::airzone::entity::select::grille_angles::state::45deg%]", + "40deg": "[%key:component::airzone::entity::select::grille_angles::state::40deg%]" + } + }, "sleep_times": { + "name": "Sleep", "state": { "off": "[%key:common::state::off%]", "30m": "30 minutes", @@ -42,6 +61,11 @@ "90m": "90 minutes" } } + }, + "sensor": { + "rssi": { + "name": "RSSI" + } } } } diff --git a/homeassistant/components/airzone/water_heater.py b/homeassistant/components/airzone/water_heater.py index b19aa36449c..58164edf3e9 100644 --- a/homeassistant/components/airzone/water_heater.py +++ b/homeassistant/components/airzone/water_heater.py @@ -9,7 +9,6 @@ from aioairzone.const import ( API_ACS_POWER_MODE, API_ACS_SET_POINT, AZD_HOT_WATER, - AZD_NAME, AZD_OPERATION, AZD_OPERATIONS, AZD_TEMP, @@ -67,6 +66,7 @@ async def async_setup_entry( class AirzoneWaterHeater(AirzoneHotWaterEntity, WaterHeaterEntity): """Define an Airzone Water Heater.""" + _attr_name = None _attr_supported_features = ( WaterHeaterEntityFeature.TARGET_TEMPERATURE | WaterHeaterEntityFeature.ON_OFF @@ -81,7 +81,6 @@ class AirzoneWaterHeater(AirzoneHotWaterEntity, WaterHeaterEntity): """Initialize Airzone water heater entity.""" super().__init__(coordinator, entry) - self._attr_name = self.get_airzone_value(AZD_NAME) self._attr_unique_id = f"{self._attr_unique_id}_dhw" self._attr_operation_list = [ OPERATION_LIB_TO_HASS[operation] diff --git a/homeassistant/components/airzone_cloud/__init__.py b/homeassistant/components/airzone_cloud/__init__.py index 38c764d4889..7e787ef4c69 100644 --- a/homeassistant/components/airzone_cloud/__init__.py +++ b/homeassistant/components/airzone_cloud/__init__.py @@ -46,7 +46,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) + coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN].pop(entry.entry_id) + await coordinator.airzone.logout() return unload_ok diff --git a/homeassistant/components/airzone_cloud/binary_sensor.py b/homeassistant/components/airzone_cloud/binary_sensor.py index a364ad0d753..2a182b7b487 100644 --- a/homeassistant/components/airzone_cloud/binary_sensor.py +++ b/homeassistant/components/airzone_cloud/binary_sensor.py @@ -159,8 +159,6 @@ class AirzoneBinarySensor(AirzoneEntity, BinarySensorEntity): class AirzoneAidooBinarySensor(AirzoneAidooEntity, AirzoneBinarySensor): """Define an Airzone Cloud Aidoo binary sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: AirzoneUpdateCoordinator, @@ -180,8 +178,6 @@ class AirzoneAidooBinarySensor(AirzoneAidooEntity, AirzoneBinarySensor): class AirzoneSystemBinarySensor(AirzoneSystemEntity, AirzoneBinarySensor): """Define an Airzone Cloud System binary sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: AirzoneUpdateCoordinator, @@ -201,8 +197,6 @@ class AirzoneSystemBinarySensor(AirzoneSystemEntity, AirzoneBinarySensor): class AirzoneZoneBinarySensor(AirzoneZoneEntity, AirzoneBinarySensor): """Define an Airzone Cloud Zone binary sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: AirzoneUpdateCoordinator, diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py index 1fe5e45ee44..e076edc1f5b 100644 --- a/homeassistant/components/airzone_cloud/climate.py +++ b/homeassistant/components/airzone_cloud/climate.py @@ -32,6 +32,7 @@ from aioairzone_cloud.const import ( ) from homeassistant.components.climate import ( + ATTR_HVAC_MODE, ClimateEntity, ClimateEntityFeature, HVACAction, @@ -142,7 +143,6 @@ async def async_setup_entry( class AirzoneClimate(AirzoneEntity, ClimateEntity): """Define an Airzone Cloud climate.""" - _attr_has_entity_name = True _attr_name = None _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.CELSIUS @@ -205,6 +205,9 @@ class AirzoneDeviceClimate(AirzoneClimate): } await self._async_update_params(params) + if ATTR_HVAC_MODE in kwargs: + await self.async_set_hvac_mode(kwargs[ATTR_HVAC_MODE]) + class AirzoneDeviceGroupClimate(AirzoneClimate): """Define an Airzone Cloud DeviceGroup base class.""" @@ -239,6 +242,9 @@ class AirzoneDeviceGroupClimate(AirzoneClimate): } await self._async_update_params(params) + if ATTR_HVAC_MODE in kwargs: + await self.async_set_hvac_mode(kwargs[ATTR_HVAC_MODE]) + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" params: dict[str, Any] = { @@ -387,4 +393,6 @@ class AirzoneZoneClimate(AirzoneZoneEntity, AirzoneDeviceClimate): await self._async_update_params(params) if slave_raise: - raise HomeAssistantError(f"Mode can't be changed on slave zone {self.name}") + raise HomeAssistantError( + f"Mode can't be changed on slave zone {self.entity_id}" + ) diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py index d5dd0cfcfb4..a175167be5a 100644 --- a/homeassistant/components/airzone_cloud/entity.py +++ b/homeassistant/components/airzone_cloud/entity.py @@ -34,6 +34,8 @@ _LOGGER = logging.getLogger(__name__) class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator], ABC): """Define an Airzone Cloud entity.""" + _attr_has_entity_name = True + @property def available(self) -> bool: """Return Airzone Cloud entity availability.""" @@ -78,14 +80,14 @@ class AirzoneAidooEntity(AirzoneEntity): async def _async_update_params(self, params: dict[str, Any]) -> None: """Send Aidoo parameters to Cloud API.""" - _LOGGER.debug("aidoo=%s: update_params=%s", self.name, params) + _LOGGER.debug("aidoo=%s: update_params=%s", self.entity_id, params) try: await self.coordinator.airzone.api_set_aidoo_id_params( self.aidoo_id, params ) except AirzoneCloudError as error: raise HomeAssistantError( - f"Failed to set {self.name} params: {error}" + f"Failed to set {self.entity_id} params: {error}" ) from error self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) @@ -120,14 +122,14 @@ class AirzoneGroupEntity(AirzoneEntity): async def _async_update_params(self, params: dict[str, Any]) -> None: """Send Group parameters to Cloud API.""" - _LOGGER.debug("group=%s: update_params=%s", self.name, params) + _LOGGER.debug("group=%s: update_params=%s", self.entity_id, params) try: await self.coordinator.airzone.api_set_group_id_params( self.group_id, params ) except AirzoneCloudError as error: raise HomeAssistantError( - f"Failed to set {self.name} params: {error}" + f"Failed to set {self.entity_id} params: {error}" ) from error self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) @@ -162,14 +164,18 @@ class AirzoneInstallationEntity(AirzoneEntity): async def _async_update_params(self, params: dict[str, Any]) -> None: """Send Installation parameters to Cloud API.""" - _LOGGER.debug("installation=%s: update_params=%s", self.name, params) + _LOGGER.debug( + "installation=%s: update_params=%s", + self.entity_id, + params, + ) try: await self.coordinator.airzone.api_set_installation_id_params( self.inst_id, params ) except AirzoneCloudError as error: raise HomeAssistantError( - f"Failed to set {self.name} params: {error}" + f"Failed to set {self.entity_id} params: {error}" ) from error self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) @@ -265,12 +271,12 @@ class AirzoneZoneEntity(AirzoneEntity): async def _async_update_params(self, params: dict[str, Any]) -> None: """Send Zone parameters to Cloud API.""" - _LOGGER.debug("zone=%s: update_params=%s", self.name, params) + _LOGGER.debug("zone=%s: update_params=%s", self.entity_id, params) try: await self.coordinator.airzone.api_set_zone_id_params(self.zone_id, params) except AirzoneCloudError as error: raise HomeAssistantError( - f"Failed to set {self.name} params: {error}" + f"Failed to set {self.entity_id} params: {error}" ) from error self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index ea22487f4a2..ab8e08835a3 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.3.5"] + "requirements": ["aioairzone-cloud==0.3.6"] } diff --git a/homeassistant/components/airzone_cloud/sensor.py b/homeassistant/components/airzone_cloud/sensor.py index c33838029b4..f45fd248cd5 100644 --- a/homeassistant/components/airzone_cloud/sensor.py +++ b/homeassistant/components/airzone_cloud/sensor.py @@ -141,8 +141,6 @@ class AirzoneSensor(AirzoneEntity, SensorEntity): class AirzoneAidooSensor(AirzoneAidooEntity, AirzoneSensor): """Define an Airzone Cloud Aidoo sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: AirzoneUpdateCoordinator, @@ -162,8 +160,6 @@ class AirzoneAidooSensor(AirzoneAidooEntity, AirzoneSensor): class AirzoneWebServerSensor(AirzoneWebServerEntity, AirzoneSensor): """Define an Airzone Cloud WebServer sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: AirzoneUpdateCoordinator, @@ -183,8 +179,6 @@ class AirzoneWebServerSensor(AirzoneWebServerEntity, AirzoneSensor): class AirzoneZoneSensor(AirzoneZoneEntity, AirzoneSensor): """Define an Airzone Cloud Zone sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: AirzoneUpdateCoordinator, diff --git a/homeassistant/components/alarmdecoder/strings.json b/homeassistant/components/alarmdecoder/strings.json index d7ac882bb82..dd698201b09 100644 --- a/homeassistant/components/alarmdecoder/strings.json +++ b/homeassistant/components/alarmdecoder/strings.json @@ -14,6 +14,10 @@ "port": "[%key:common::config_flow::data::port%]", "device_baudrate": "Device Baud Rate", "device_path": "Device Path" + }, + "data_description": { + "host": "The hostname or IP address of the AlarmDecoder device that is connected to your alarm panel.", + "port": "The port on which AlarmDecoder is accessible (for example, 10000)" } } }, diff --git a/homeassistant/components/android_ip_webcam/strings.json b/homeassistant/components/android_ip_webcam/strings.json index db21a690984..57e5452b900 100644 --- a/homeassistant/components/android_ip_webcam/strings.json +++ b/homeassistant/components/android_ip_webcam/strings.json @@ -7,6 +7,9 @@ "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The IP address of the device running the Android IP Webcam app. The IP address is shown in the app once you start the server." } } }, diff --git a/homeassistant/components/apcupsd/__init__.py b/homeassistant/components/apcupsd/__init__.py index 8d7c6b2f46d..550e1014d2a 100644 --- a/homeassistant/components/apcupsd/__init__.py +++ b/homeassistant/components/apcupsd/__init__.py @@ -1,44 +1,34 @@ """Support for APCUPSd via its Network Information Server (NIS).""" from __future__ import annotations -from datetime import timedelta import logging -from typing import Any, Final - -from apcaccess import status +from typing import Final from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.util import Throttle + +from .const import DOMAIN +from .coordinator import APCUPSdCoordinator _LOGGER = logging.getLogger(__name__) -DOMAIN: Final = "apcupsd" -VALUE_ONLINE: Final = 8 PLATFORMS: Final = (Platform.BINARY_SENSOR, Platform.SENSOR) -MIN_TIME_BETWEEN_UPDATES: Final = timedelta(seconds=60) CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Use config values to set up a function enabling status retrieval.""" - data_service = APCUPSdData( - config_entry.data[CONF_HOST], config_entry.data[CONF_PORT] - ) + host, port = config_entry.data[CONF_HOST], config_entry.data[CONF_PORT] + coordinator = APCUPSdCoordinator(hass, host, port) - try: - await hass.async_add_executor_job(data_service.update) - except OSError as ex: - _LOGGER.error("Failure while testing APCUPSd status retrieval: %s", ex) - return False + await coordinator.async_config_entry_first_refresh() - # Store the data service object. + # Store the coordinator for later uses. hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][config_entry.entry_id] = data_service + hass.data[DOMAIN][config_entry.entry_id] = coordinator # Forward the config entries to the supported platforms. await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -51,66 +41,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok and DOMAIN in hass.data: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class APCUPSdData: - """Stores the data retrieved from APCUPSd. - - For each entity to use, acts as the single point responsible for fetching - updates from the server. - """ - - def __init__(self, host: str, port: int) -> None: - """Initialize the data object.""" - self._host = host - self._port = port - self.status: dict[str, str] = {} - - @property - def name(self) -> str | None: - """Return the name of the UPS, if available.""" - return self.status.get("UPSNAME") - - @property - def model(self) -> str | None: - """Return the model of the UPS, if available.""" - # Different UPS models may report slightly different keys for model, here we - # try them all. - for model_key in ("APCMODEL", "MODEL"): - if model_key in self.status: - return self.status[model_key] - return None - - @property - def serial_no(self) -> str | None: - """Return the unique serial number of the UPS, if available.""" - return self.status.get("SERIALNO") - - @property - def statflag(self) -> str | None: - """Return the STATFLAG indicating the status of the UPS, if available.""" - return self.status.get("STATFLAG") - - @property - def device_info(self) -> DeviceInfo | None: - """Return the DeviceInfo of this APC UPS for the sensors, if serial number is available.""" - if self.serial_no is None: - return None - - return DeviceInfo( - identifiers={(DOMAIN, self.serial_no)}, - model=self.model, - manufacturer="APC", - name=self.name if self.name is not None else "APC UPS", - hw_version=self.status.get("FIRMWARE"), - sw_version=self.status.get("VERSION"), - ) - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self, **kwargs: Any) -> None: - """Fetch the latest status from APCUPSd. - - Note that the result dict uses upper case for each resource, where our - integration uses lower cases as keys internally. - """ - self.status = status.parse(status.get(host=self._host, port=self._port)) diff --git a/homeassistant/components/apcupsd/binary_sensor.py b/homeassistant/components/apcupsd/binary_sensor.py index bac8d18d58b..76e88689ca5 100644 --- a/homeassistant/components/apcupsd/binary_sensor.py +++ b/homeassistant/components/apcupsd/binary_sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Final from homeassistant.components.binary_sensor import ( BinarySensorEntity, @@ -10,8 +11,9 @@ from homeassistant.components.binary_sensor import ( 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 . import DOMAIN, VALUE_ONLINE, APCUPSdData +from . import DOMAIN, APCUPSdCoordinator _LOGGER = logging.getLogger(__name__) _DESCRIPTION = BinarySensorEntityDescription( @@ -19,6 +21,8 @@ _DESCRIPTION = BinarySensorEntityDescription( name="UPS Online Status", icon="mdi:heart", ) +# The bit in STATFLAG that indicates the online status of the APC UPS. +_VALUE_ONLINE_MASK: Final = 0b1000 async def async_setup_entry( @@ -27,50 +31,36 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up an APCUPSd Online Status binary sensor.""" - data_service: APCUPSdData = hass.data[DOMAIN][config_entry.entry_id] + coordinator: APCUPSdCoordinator = hass.data[DOMAIN][config_entry.entry_id] # Do not create the binary sensor if APCUPSd does not provide STATFLAG field for us # to determine the online status. - if data_service.statflag is None: + if _DESCRIPTION.key.upper() not in coordinator.data: return - async_add_entities( - [OnlineStatus(data_service, _DESCRIPTION)], - update_before_add=True, - ) + async_add_entities([OnlineStatus(coordinator, _DESCRIPTION)]) -class OnlineStatus(BinarySensorEntity): +class OnlineStatus(CoordinatorEntity[APCUPSdCoordinator], BinarySensorEntity): """Representation of a UPS online status.""" def __init__( self, - data_service: APCUPSdData, + coordinator: APCUPSdCoordinator, description: BinarySensorEntityDescription, ) -> None: """Initialize the APCUPSd binary device.""" + super().__init__(coordinator, context=description.key.upper()) + # Set up unique id and device info if serial number is available. - if (serial_no := data_service.serial_no) is not None: + if (serial_no := coordinator.ups_serial_no) is not None: self._attr_unique_id = f"{serial_no}_{description.key}" - self._attr_device_info = data_service.device_info - self.entity_description = description - self._data_service = data_service + self._attr_device_info = coordinator.device_info - def update(self) -> None: - """Get the status report from APCUPSd and set this entity's state.""" - try: - self._data_service.update() - except OSError as ex: - if self._attr_available: - self._attr_available = False - _LOGGER.exception("Got exception while fetching state: %s", ex) - return - - self._attr_available = True + @property + def is_on(self) -> bool | None: + """Returns true if the UPS is online.""" + # Check if ONLINE bit is set in STATFLAG. key = self.entity_description.key.upper() - if key not in self._data_service.status: - self._attr_is_on = None - return - - self._attr_is_on = int(self._data_service.status[key], 16) & VALUE_ONLINE > 0 + return int(self.coordinator.data[key], 16) & _VALUE_ONLINE_MASK != 0 diff --git a/homeassistant/components/apcupsd/config_flow.py b/homeassistant/components/apcupsd/config_flow.py index f1ce20694c7..57002d7a2b2 100644 --- a/homeassistant/components/apcupsd/config_flow.py +++ b/homeassistant/components/apcupsd/config_flow.py @@ -1,6 +1,7 @@ """Config flow for APCUPSd integration.""" from __future__ import annotations +import asyncio from typing import Any import voluptuous as vol @@ -10,8 +11,9 @@ from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.update_coordinator import UpdateFailed -from . import DOMAIN, APCUPSdData +from . import DOMAIN, APCUPSdCoordinator _PORT_SELECTOR = vol.All( selector.NumberSelector( @@ -43,36 +45,37 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form(step_id="user", data_schema=_SCHEMA) + host, port = user_input[CONF_HOST], user_input[CONF_PORT] + # Abort if an entry with same host and port is present. - self._async_abort_entries_match( - {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} - ) + self._async_abort_entries_match({CONF_HOST: host, CONF_PORT: port}) # Test the connection to the host and get the current status for serial number. - data_service = APCUPSdData(user_input[CONF_HOST], user_input[CONF_PORT]) - try: - await self.hass.async_add_executor_job(data_service.update) - except OSError: + coordinator = APCUPSdCoordinator(self.hass, host, port) + + await coordinator.async_request_refresh() + await self.hass.async_block_till_done() + if isinstance(coordinator.last_exception, (UpdateFailed, asyncio.TimeoutError)): errors = {"base": "cannot_connect"} return self.async_show_form( step_id="user", data_schema=_SCHEMA, errors=errors ) - if not data_service.status: + if not coordinator.data: return self.async_abort(reason="no_status") # We _try_ to use the serial number of the UPS as the unique id since this field # is not guaranteed to exist on all APC UPS models. - await self.async_set_unique_id(data_service.serial_no) + await self.async_set_unique_id(coordinator.ups_serial_no) self._abort_if_unique_id_configured() title = "APC UPS" - if data_service.name is not None: - title = data_service.name - elif data_service.model is not None: - title = data_service.model - elif data_service.serial_no is not None: - title = data_service.serial_no + if coordinator.ups_name is not None: + title = coordinator.ups_name + elif coordinator.ups_model is not None: + title = coordinator.ups_model + elif coordinator.ups_serial_no is not None: + title = coordinator.ups_serial_no return self.async_create_entry( title=title, diff --git a/homeassistant/components/apcupsd/const.py b/homeassistant/components/apcupsd/const.py new file mode 100644 index 00000000000..cacc9e29369 --- /dev/null +++ b/homeassistant/components/apcupsd/const.py @@ -0,0 +1,4 @@ +"""Constants for APCUPSd component.""" +from typing import Final + +DOMAIN: Final = "apcupsd" diff --git a/homeassistant/components/apcupsd/coordinator.py b/homeassistant/components/apcupsd/coordinator.py new file mode 100644 index 00000000000..321da56095a --- /dev/null +++ b/homeassistant/components/apcupsd/coordinator.py @@ -0,0 +1,102 @@ +"""Support for APCUPSd via its Network Information Server (NIS).""" +from __future__ import annotations + +import asyncio +from collections import OrderedDict +from datetime import timedelta +import logging +from typing import Final + +from apcaccess import status + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + REQUEST_REFRESH_DEFAULT_IMMEDIATE, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +UPDATE_INTERVAL: Final = timedelta(seconds=60) +REQUEST_REFRESH_COOLDOWN: Final = 5 + + +class APCUPSdCoordinator(DataUpdateCoordinator[OrderedDict[str, str]]): + """Store and coordinate the data retrieved from APCUPSd for all sensors. + + For each entity to use, acts as the single point responsible for fetching + updates from the server. + """ + + def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: + """Initialize the data object.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + request_refresh_debouncer=Debouncer( + hass, + _LOGGER, + cooldown=REQUEST_REFRESH_COOLDOWN, + immediate=REQUEST_REFRESH_DEFAULT_IMMEDIATE, + ), + ) + self._host = host + self._port = port + + @property + def ups_name(self) -> str | None: + """Return the name of the UPS, if available.""" + return self.data.get("UPSNAME") + + @property + def ups_model(self) -> str | None: + """Return the model of the UPS, if available.""" + # Different UPS models may report slightly different keys for model, here we + # try them all. + for model_key in ("APCMODEL", "MODEL"): + if model_key in self.data: + return self.data[model_key] + return None + + @property + def ups_serial_no(self) -> str | None: + """Return the unique serial number of the UPS, if available.""" + return self.data.get("SERIALNO") + + @property + def device_info(self) -> DeviceInfo | None: + """Return the DeviceInfo of this APC UPS, if serial number is available.""" + if not self.ups_serial_no: + return None + + return DeviceInfo( + identifiers={(DOMAIN, self.ups_serial_no)}, + model=self.ups_model, + manufacturer="APC", + name=self.ups_name if self.ups_name else "APC UPS", + hw_version=self.data.get("FIRMWARE"), + sw_version=self.data.get("VERSION"), + ) + + async def _async_update_data(self) -> OrderedDict[str, str]: + """Fetch the latest status from APCUPSd. + + Note that the result dict uses upper case for each resource, where our + integration uses lower cases as keys internally. + """ + + async with asyncio.timeout(10): + try: + raw = await self.hass.async_add_executor_job( + status.get, self._host, self._port + ) + result: OrderedDict[str, str] = status.parse(raw) + return result + except OSError as error: + raise UpdateFailed(error) from error diff --git a/homeassistant/components/apcupsd/manifest.json b/homeassistant/components/apcupsd/manifest.json index cd7e2a116b3..55b66f0c0a0 100644 --- a/homeassistant/components/apcupsd/manifest.json +++ b/homeassistant/components/apcupsd/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/apcupsd", "iot_class": "local_polling", "loggers": ["apcaccess"], + "quality_scale": "silver", "requirements": ["apcaccess==0.0.13"] } diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 745be7e2d63..71dc9940b72 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -20,10 +20,11 @@ from homeassistant.const import ( UnitOfTemperature, UnitOfTime, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DOMAIN, APCUPSdData +from . import DOMAIN, APCUPSdCoordinator _LOGGER = logging.getLogger(__name__) @@ -452,11 +453,11 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the APCUPSd sensors from config entries.""" - data_service: APCUPSdData = hass.data[DOMAIN][config_entry.entry_id] + coordinator: APCUPSdCoordinator = hass.data[DOMAIN][config_entry.entry_id] - # The resources from data service are in upper-case by default, but we use - # lower cases throughout this integration. - available_resources: set[str] = {k.lower() for k, _ in data_service.status.items()} + # The resource keys in the data dict collected in the coordinator is in upper-case + # by default, but we use lower cases throughout this integration. + available_resources: set[str] = {k.lower() for k, _ in coordinator.data.items()} entities = [] for resource in available_resources: @@ -464,9 +465,9 @@ async def async_setup_entry( _LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper()) continue - entities.append(APCUPSdSensor(data_service, SENSORS[resource])) + entities.append(APCUPSdSensor(coordinator, SENSORS[resource])) - async_add_entities(entities, update_before_add=True) + async_add_entities(entities) def infer_unit(value: str) -> tuple[str, str | None]: @@ -483,41 +484,36 @@ def infer_unit(value: str) -> tuple[str, str | None]: return value, None -class APCUPSdSensor(SensorEntity): +class APCUPSdSensor(CoordinatorEntity[APCUPSdCoordinator], SensorEntity): """Representation of a sensor entity for APCUPSd status values.""" def __init__( self, - data_service: APCUPSdData, + coordinator: APCUPSdCoordinator, description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" + super().__init__(coordinator=coordinator, context=description.key.upper()) + # Set up unique id and device info if serial number is available. - if (serial_no := data_service.serial_no) is not None: + if (serial_no := coordinator.ups_serial_no) is not None: self._attr_unique_id = f"{serial_no}_{description.key}" - self._attr_device_info = data_service.device_info self.entity_description = description - self._data_service = data_service + self._attr_device_info = coordinator.device_info - def update(self) -> None: - """Get the latest status and use it to update our sensor state.""" - try: - self._data_service.update() - except OSError as ex: - if self._attr_available: - self._attr_available = False - _LOGGER.exception("Got exception while fetching state: %s", ex) - return + # Initial update of attributes. + self._update_attrs() - self._attr_available = True + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attrs() + self.async_write_ha_state() + + def _update_attrs(self) -> None: + """Update sensor attributes based on coordinator data.""" key = self.entity_description.key.upper() - if key not in self._data_service.status: - self._attr_native_value = None - return - - self._attr_native_value, inferred_unit = infer_unit( - self._data_service.status[key] - ) + self._attr_native_value, inferred_unit = infer_unit(self.coordinator.data[key]) if not self.native_unit_of_measurement: self._attr_native_unit_of_measurement = inferred_unit diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 0cade0f81ca..057e85613fd 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -1,9 +1,11 @@ """Rest API for Home Assistant.""" import asyncio -from asyncio import timeout +from asyncio import shield, timeout +from collections.abc import Collection from functools import lru_cache from http import HTTPStatus import logging +from typing import Any from aiohttp import web from aiohttp.web_exceptions import HTTPBadRequest @@ -16,6 +18,7 @@ from homeassistant.components.http import HomeAssistantView, require_admin from homeassistant.const import ( CONTENT_TYPE_JSON, EVENT_HOMEASSISTANT_STOP, + EVENT_STATE_CHANGED, MATCH_ALL, URL_API, URL_API_COMPONENTS, @@ -38,10 +41,12 @@ from homeassistant.exceptions import ( Unauthorized, ) from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers.event import EventStateChangedData from homeassistant.helpers.json import json_dumps from homeassistant.helpers.service import async_get_all_descriptions -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.util.json import json_loads +from homeassistant.util.read_only_dict import ReadOnlyDict _LOGGER = logging.getLogger(__name__) @@ -57,6 +62,7 @@ ATTR_VERSION = "version" DOMAIN = "api" STREAM_PING_PAYLOAD = "ping" STREAM_PING_INTERVAL = 50 # seconds +SERVICE_WAIT_TIMEOUT = 10 CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -211,7 +217,9 @@ class APIStatesView(HomeAssistantView): if entity_perm(state.entity_id, "read") ) response = web.Response( - body=f'[{",".join(states)}]', content_type=CONTENT_TYPE_JSON + body=f'[{",".join(states)}]', + content_type=CONTENT_TYPE_JSON, + zlib_executor_size=32768, ) response.enable_compression() return response @@ -369,19 +377,30 @@ class APIDomainServicesView(HomeAssistantView): ) context = self.context(request) + changed_states: list[ReadOnlyDict[str, Collection[Any]]] = [] + + @ha.callback + def _async_save_changed_entities( + event: EventType[EventStateChangedData], + ) -> None: + if event.context == context and (state := event.data["new_state"]): + changed_states.append(state.as_dict()) + + cancel_listen = hass.bus.async_listen( + EVENT_STATE_CHANGED, _async_save_changed_entities, run_immediately=True + ) try: - await hass.services.async_call( - domain, service, data, blocking=True, context=context + # shield the service call from cancellation on connection drop + await shield( + hass.services.async_call( + domain, service, data, blocking=True, context=context + ) ) except (vol.Invalid, ServiceNotFound) as ex: raise HTTPBadRequest() from ex - - changed_states = [] - - for state in hass.states.async_all(): - if state.context is context: - changed_states.append(state) + finally: + cancel_listen() return self.json(changed_states) diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 64fe9e1f5f4..6d00f26ee15 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -9,7 +9,13 @@ from homeassistant.components import stt from homeassistant.core import Context, HomeAssistant from homeassistant.helpers.typing import ConfigType -from .const import CONF_DEBUG_RECORDING_DIR, DATA_CONFIG, DATA_LAST_WAKE_UP, DOMAIN +from .const import ( + CONF_DEBUG_RECORDING_DIR, + DATA_CONFIG, + DATA_LAST_WAKE_UP, + DOMAIN, + EVENT_RECORDING, +) from .error import PipelineNotFound from .pipeline import ( AudioSettings, @@ -40,6 +46,7 @@ __all__ = ( "PipelineEventType", "PipelineNotFound", "WakeWordSettings", + "EVENT_RECORDING", ) CONFIG_SCHEMA = vol.Schema( diff --git a/homeassistant/components/assist_pipeline/const.py b/homeassistant/components/assist_pipeline/const.py index 84b49fc18fa..091b19db69e 100644 --- a/homeassistant/components/assist_pipeline/const.py +++ b/homeassistant/components/assist_pipeline/const.py @@ -11,3 +11,5 @@ CONF_DEBUG_RECORDING_DIR = "debug_recording_dir" DATA_LAST_WAKE_UP = f"{DOMAIN}.last_wake_up" DEFAULT_WAKE_WORD_COOLDOWN = 2 # seconds + +EVENT_RECORDING = f"{DOMAIN}_recording" diff --git a/homeassistant/components/assist_pipeline/logbook.py b/homeassistant/components/assist_pipeline/logbook.py new file mode 100644 index 00000000000..0c00c57adb9 --- /dev/null +++ b/homeassistant/components/assist_pipeline/logbook.py @@ -0,0 +1,39 @@ +"""Describe assist_pipeline logbook events.""" +from __future__ import annotations + +from collections.abc import Callable + +from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import Event, HomeAssistant, callback +import homeassistant.helpers.device_registry as dr + +from .const import DOMAIN, EVENT_RECORDING + + +@callback +def async_describe_events( + hass: HomeAssistant, + async_describe_event: Callable[[str, str, Callable[[Event], dict[str, str]]], None], +) -> None: + """Describe logbook events.""" + device_registry = dr.async_get(hass) + + @callback + def async_describe_logbook_event(event: Event) -> dict[str, str]: + """Describe logbook event.""" + device: dr.DeviceEntry | None = None + device_name: str = "Unknown device" + + device = device_registry.devices[event.data[ATTR_DEVICE_ID]] + if device: + device_name = device.name_by_user or device.name or "Unknown device" + + message = f"{device_name} captured an audio sample" + + return { + LOGBOOK_ENTRY_NAME: device_name, + LOGBOOK_ENTRY_MESSAGE: message, + } + + async_describe_event(DOMAIN, EVENT_RECORDING, async_describe_logbook_event) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 1e1c0b6f495..4f2a9a8d99b 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -320,7 +320,7 @@ class Pipeline: wake_word_entity: str | None wake_word_id: str | None - id: str = field(default_factory=ulid_util.ulid) + id: str = field(default_factory=ulid_util.ulid_now) @classmethod def from_json(cls, data: dict[str, Any]) -> Pipeline: @@ -482,7 +482,7 @@ class PipelineRun: wake_word_settings: WakeWordSettings | None = None audio_settings: AudioSettings = field(default_factory=AudioSettings) - id: str = field(default_factory=ulid_util.ulid) + id: str = field(default_factory=ulid_util.ulid_now) stt_provider: stt.SpeechToTextEntity | stt.Provider = field(init=False, repr=False) tts_engine: str = field(init=False, repr=False) tts_options: dict | None = field(init=False, default=None) @@ -503,6 +503,9 @@ class PipelineRun: audio_processor_buffer: AudioBuffer = field(init=False, repr=False) """Buffer used when splitting audio into chunks for audio processing""" + _device_id: str | None = None + """Optional device id set during run start.""" + def __post_init__(self) -> None: """Set language for pipeline.""" self.language = self.pipeline.language or self.hass.config.language @@ -554,7 +557,8 @@ class PipelineRun: def start(self, device_id: str | None) -> None: """Emit run start event.""" - self._start_debug_recording_thread(device_id) + self._device_id = device_id + self._start_debug_recording_thread() data = { "pipeline": self.pipeline.id, @@ -567,6 +571,9 @@ class PipelineRun: async def end(self) -> None: """Emit run end event.""" + # Signal end of stream to listeners + self._capture_chunk(None) + # Stop the recording thread before emitting run-end. # This ensures that files are properly closed if the event handler reads them. await self._stop_debug_recording_thread() @@ -746,9 +753,7 @@ class PipelineRun: if self.abort_wake_word_detection: raise WakeWordDetectionAborted - if self.debug_recording_queue is not None: - self.debug_recording_queue.put_nowait(chunk.audio) - + self._capture_chunk(chunk.audio) yield chunk.audio, chunk.timestamp_ms # Wake-word-detection occurs *after* the wake word was actually @@ -870,8 +875,7 @@ class PipelineRun: chunk_seconds = AUDIO_PROCESSOR_SAMPLES / sample_rate sent_vad_start = False async for chunk in audio_stream: - if self.debug_recording_queue is not None: - self.debug_recording_queue.put_nowait(chunk.audio) + self._capture_chunk(chunk.audio) if stt_vad is not None: if not stt_vad.process(chunk_seconds, chunk.is_speech): @@ -971,12 +975,16 @@ class PipelineRun: # pipeline.tts_engine can't be None or this function is not called engine = cast(str, self.pipeline.tts_engine) - tts_options = {} + tts_options: dict[str, Any] = {} if self.pipeline.tts_voice is not None: tts_options[tts.ATTR_VOICE] = self.pipeline.tts_voice if self.tts_audio_output is not None: - tts_options[tts.ATTR_AUDIO_OUTPUT] = self.tts_audio_output + tts_options[tts.ATTR_PREFERRED_FORMAT] = self.tts_audio_output + if self.tts_audio_output == "wav": + # 16 Khz, 16-bit mono + tts_options[tts.ATTR_PREFERRED_SAMPLE_RATE] = 16000 + tts_options[tts.ATTR_PREFERRED_SAMPLE_CHANNELS] = 1 try: options_supported = await tts.async_support_options( @@ -1016,44 +1024,64 @@ class PipelineRun: ) ) - try: - # Synthesize audio and get URL - tts_media_id = tts_generate_media_source_id( - self.hass, - tts_input, - engine=self.tts_engine, - language=self.pipeline.tts_language, - options=self.tts_options, - ) - tts_media = await media_source.async_resolve_media( - self.hass, - tts_media_id, - None, - ) - except Exception as src_error: - _LOGGER.exception("Unexpected error during text-to-speech") - raise TextToSpeechError( - code="tts-failed", - message="Unexpected error during text-to-speech", - ) from src_error + if tts_input := tts_input.strip(): + try: + # Synthesize audio and get URL + tts_media_id = tts_generate_media_source_id( + self.hass, + tts_input, + engine=self.tts_engine, + language=self.pipeline.tts_language, + options=self.tts_options, + ) + tts_media = await media_source.async_resolve_media( + self.hass, + tts_media_id, + None, + ) + except Exception as src_error: + _LOGGER.exception("Unexpected error during text-to-speech") + raise TextToSpeechError( + code="tts-failed", + message="Unexpected error during text-to-speech", + ) from src_error - _LOGGER.debug("TTS result %s", tts_media) + _LOGGER.debug("TTS result %s", tts_media) + tts_output = { + "media_id": tts_media_id, + **asdict(tts_media), + } + else: + tts_output = {} self.process_event( - PipelineEvent( - PipelineEventType.TTS_END, - { - "tts_output": { - "media_id": tts_media_id, - **asdict(tts_media), - } - }, - ) + PipelineEvent(PipelineEventType.TTS_END, {"tts_output": tts_output}) ) return tts_media.url - def _start_debug_recording_thread(self, device_id: str | None) -> None: + def _capture_chunk(self, audio_bytes: bytes | None) -> None: + """Forward audio chunk to various capturing mechanisms.""" + if self.debug_recording_queue is not None: + # Forward to debug WAV file recording + self.debug_recording_queue.put_nowait(audio_bytes) + + if self._device_id is None: + return + + # Forward to device audio capture + pipeline_data: PipelineData = self.hass.data[DOMAIN] + audio_queue = pipeline_data.device_audio_queues.get(self._device_id) + if audio_queue is None: + return + + try: + audio_queue.queue.put_nowait(audio_bytes) + except asyncio.QueueFull: + audio_queue.overflow = True + _LOGGER.warning("Audio queue full for device %s", self._device_id) + + def _start_debug_recording_thread(self) -> None: """Start thread to record wake/stt audio if debug_recording_dir is set.""" if self.debug_recording_thread is not None: # Already started @@ -1064,7 +1092,7 @@ class PipelineRun: if debug_recording_dir := self.hass.data[DATA_CONFIG].get( CONF_DEBUG_RECORDING_DIR ): - if device_id is None: + if self._device_id is None: # // run_recording_dir = ( Path(debug_recording_dir) @@ -1075,7 +1103,7 @@ class PipelineRun: # /// run_recording_dir = ( Path(debug_recording_dir) - / device_id + / self._device_id / self.pipeline.name / str(time.monotonic_ns()) ) @@ -1096,8 +1124,8 @@ class PipelineRun: # Not running return - # Signal thread to stop gracefully - self.debug_recording_queue.put(None) + # NOTE: Expecting a None to have been put in self.debug_recording_queue + # in self.end() to signal the thread to stop. # Wait until the thread has finished to ensure that files are fully written await self.hass.async_add_executor_job(self.debug_recording_thread.join) @@ -1286,9 +1314,9 @@ class PipelineInput: if stt_audio_buffer: # Send audio in the buffer first to speech-to-text, then move on to stt_stream. # This is basically an async itertools.chain. - async def buffer_then_audio_stream() -> AsyncGenerator[ - ProcessedAudioChunk, None - ]: + async def buffer_then_audio_stream() -> ( + AsyncGenerator[ProcessedAudioChunk, None] + ): # Buffered audio for chunk in stt_audio_buffer: yield chunk @@ -1447,7 +1475,7 @@ class PipelineStorageCollection( @callback def _get_suggested_id(self, info: dict) -> str: """Suggest an ID based on the config.""" - return ulid_util.ulid() + return ulid_util.ulid_now() async def _update_data(self, item: Pipeline, update_data: dict) -> Pipeline: """Return a new updated item.""" @@ -1628,6 +1656,20 @@ class PipelineRuns: pipeline_run.abort_wake_word_detection = True +@dataclass +class DeviceAudioQueue: + """Audio capture queue for a satellite device.""" + + queue: asyncio.Queue[bytes | None] + """Queue of audio chunks (None = stop signal)""" + + id: str = field(default_factory=ulid_util.ulid_now) + """Unique id to ensure the correct audio queue is cleaned up in websocket API.""" + + overflow: bool = False + """Flag to be set if audio samples were dropped because the queue was full.""" + + class PipelineData: """Store and debug data stored in hass.data.""" @@ -1637,6 +1679,7 @@ class PipelineData: self.pipeline_debug: dict[str, LimitedSizeDict[str, PipelineRunDebug]] = {} self.pipeline_devices: set[str] = set() self.pipeline_runs = PipelineRuns(pipeline_store) + self.device_audio_queues: dict[str, DeviceAudioQueue] = {} @dataclass diff --git a/homeassistant/components/assist_pipeline/select.py b/homeassistant/components/assist_pipeline/select.py index 2ae46fcb9ac..83e1bd3ab36 100644 --- a/homeassistant/components/assist_pipeline/select.py +++ b/homeassistant/components/assist_pipeline/select.py @@ -93,9 +93,7 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity): if self.registry_entry and (device_id := self.registry_entry.device_id): pipeline_data.pipeline_devices.add(device_id) self.async_on_remove( - lambda: pipeline_data.pipeline_devices.discard( - device_id # type: ignore[arg-type] - ) + lambda: pipeline_data.pipeline_devices.discard(device_id) ) async def async_select_option(self, option: str) -> None: diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index fda3e266490..89cced519df 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -3,22 +3,31 @@ import asyncio # Suppressing disable=deprecated-module is needed for Python 3.11 import audioop # pylint: disable=deprecated-module +import base64 from collections.abc import AsyncGenerator, Callable +import contextlib import logging -from typing import Any +import math +from typing import Any, Final import voluptuous as vol from homeassistant.components import conversation, stt, tts, websocket_api -from homeassistant.const import MATCH_ALL +from homeassistant.const import ATTR_DEVICE_ID, ATTR_SECONDS, MATCH_ALL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.util import language as language_util -from .const import DEFAULT_PIPELINE_TIMEOUT, DEFAULT_WAKE_WORD_TIMEOUT, DOMAIN +from .const import ( + DEFAULT_PIPELINE_TIMEOUT, + DEFAULT_WAKE_WORD_TIMEOUT, + DOMAIN, + EVENT_RECORDING, +) from .error import PipelineNotFound from .pipeline import ( AudioSettings, + DeviceAudioQueue, PipelineData, PipelineError, PipelineEvent, @@ -32,6 +41,11 @@ from .pipeline import ( _LOGGER = logging.getLogger(__name__) +CAPTURE_RATE: Final = 16000 +CAPTURE_WIDTH: Final = 2 +CAPTURE_CHANNELS: Final = 1 +MAX_CAPTURE_TIMEOUT: Final = 60.0 + @callback def async_register_websocket_api(hass: HomeAssistant) -> None: @@ -40,6 +54,7 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_list_languages) websocket_api.async_register_command(hass, websocket_list_runs) websocket_api.async_register_command(hass, websocket_get_run) + websocket_api.async_register_command(hass, websocket_device_capture) @websocket_api.websocket_command( @@ -371,3 +386,100 @@ async def websocket_list_languages( else pipeline_languages }, ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "assist_pipeline/device/capture", + vol.Required("device_id"): str, + vol.Required("timeout"): vol.All( + # 0 < timeout <= MAX_CAPTURE_TIMEOUT + vol.Coerce(float), + vol.Range(min=0, min_included=False, max=MAX_CAPTURE_TIMEOUT), + ), + } +) +@websocket_api.async_response +async def websocket_device_capture( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Capture raw audio from a satellite device and forward to client.""" + pipeline_data: PipelineData = hass.data[DOMAIN] + device_id = msg["device_id"] + + # Number of seconds to record audio in wall clock time + timeout_seconds = msg["timeout"] + + # We don't know the chunk size, so the upper bound is calculated assuming a + # single sample (16 bits) per queue item. + max_queue_items = ( + # +1 for None to signal end + int(math.ceil(timeout_seconds * CAPTURE_RATE)) + 1 + ) + + audio_queue = DeviceAudioQueue(queue=asyncio.Queue(maxsize=max_queue_items)) + + # Running simultaneous captures for a single device will not work by design. + # The new capture will cause the old capture to stop. + if ( + old_audio_queue := pipeline_data.device_audio_queues.pop(device_id, None) + ) is not None: + with contextlib.suppress(asyncio.QueueFull): + # Signal other websocket command that we're taking over + old_audio_queue.queue.put_nowait(None) + + # Only one client can be capturing audio at a time + pipeline_data.device_audio_queues[device_id] = audio_queue + + def clean_up_queue() -> None: + # Clean up our audio queue + maybe_audio_queue = pipeline_data.device_audio_queues.get(device_id) + if (maybe_audio_queue is not None) and (maybe_audio_queue.id == audio_queue.id): + # Only pop if this is our queue + pipeline_data.device_audio_queues.pop(device_id) + + # Unsubscribe cleans up queue + connection.subscriptions[msg["id"]] = clean_up_queue + + # Audio will follow as events + connection.send_result(msg["id"]) + + # Record to logbook + hass.bus.async_fire( + EVENT_RECORDING, + { + ATTR_DEVICE_ID: device_id, + ATTR_SECONDS: timeout_seconds, + }, + ) + + try: + with contextlib.suppress(asyncio.TimeoutError): + async with asyncio.timeout(timeout_seconds): + while True: + # Send audio chunks encoded as base64 + audio_bytes = await audio_queue.queue.get() + if audio_bytes is None: + # Signal to stop + break + + connection.send_event( + msg["id"], + { + "type": "audio", + "rate": CAPTURE_RATE, # hertz + "width": CAPTURE_WIDTH, # bytes + "channels": CAPTURE_CHANNELS, + "audio": base64.b64encode(audio_bytes).decode("ascii"), + }, + ) + + # Capture has ended + connection.send_event( + msg["id"], {"type": "end", "overflow": audio_queue.overflow} + ) + finally: + clean_up_queue() diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py index 9e6da0ea8f7..83f99ecc76a 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -3,10 +3,14 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections import namedtuple +from collections.abc import Awaitable, Callable, Coroutine +import functools import logging -from typing import Any, cast +from typing import Any, TypeVar, cast from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy +from aiohttp import ClientSession +from pyasuswrt import AsusWrtError, AsusWrtHttp from homeassistant.const import ( CONF_HOST, @@ -17,6 +21,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.update_coordinator import UpdateFailed @@ -29,6 +34,8 @@ from .const import ( DEFAULT_INTERFACE, KEY_METHOD, KEY_SENSORS, + PROTOCOL_HTTP, + PROTOCOL_HTTPS, PROTOCOL_TELNET, SENSORS_BYTES, SENSORS_LOAD_AVG, @@ -47,9 +54,40 @@ WrtDevice = namedtuple("WrtDevice", ["ip", "name", "connected_to"]) _LOGGER = logging.getLogger(__name__) -def _get_dict(keys: list, values: list) -> dict[str, Any]: - """Create a dict from a list of keys and values.""" - return dict(zip(keys, values)) +_AsusWrtBridgeT = TypeVar("_AsusWrtBridgeT", bound="AsusWrtBridge") +_FuncType = Callable[[_AsusWrtBridgeT], Awaitable[list[Any] | dict[str, Any]]] +_ReturnFuncType = Callable[[_AsusWrtBridgeT], Coroutine[Any, Any, dict[str, Any]]] + + +def handle_errors_and_zip( + exceptions: type[Exception] | tuple[type[Exception], ...], keys: list[str] | None +) -> Callable[[_FuncType], _ReturnFuncType]: + """Run library methods and zip results or manage exceptions.""" + + def _handle_errors_and_zip(func: _FuncType) -> _ReturnFuncType: + """Run library methods and zip results or manage exceptions.""" + + @functools.wraps(func) + async def _wrapper(self: _AsusWrtBridgeT) -> dict[str, Any]: + try: + data = await func(self) + except exceptions as exc: + raise UpdateFailed(exc) from exc + + if keys is None: + if not isinstance(data, dict): + raise UpdateFailed("Received invalid data type") + return data + + if isinstance(data, dict): + return dict(zip(keys, list(data.values()))) + if not isinstance(data, list): + raise UpdateFailed("Received invalid data type") + return dict(zip(keys, data)) + + return _wrapper + + return _handle_errors_and_zip class AsusWrtBridge(ABC): @@ -60,6 +98,9 @@ class AsusWrtBridge(ABC): hass: HomeAssistant, conf: dict[str, Any], options: dict[str, Any] | None = None ) -> AsusWrtBridge: """Get Bridge instance.""" + if conf[CONF_PROTOCOL] in (PROTOCOL_HTTPS, PROTOCOL_HTTP): + session = async_get_clientsession(hass) + return AsusWrtHttpBridge(conf, session) return AsusWrtLegacyBridge(conf, options) def __init__(self, host: str) -> None: @@ -236,38 +277,135 @@ class AsusWrtLegacyBridge(AsusWrtBridge): availability = await self._api.async_find_temperature_commands() return [SENSORS_TEMPERATURES[i] for i in range(3) if availability[i]] - async def _get_bytes(self) -> dict[str, Any]: + @handle_errors_and_zip((IndexError, OSError, ValueError), SENSORS_BYTES) + async def _get_bytes(self) -> Any: """Fetch byte information from the router.""" - try: - datas = await self._api.async_get_bytes_total() - except (IndexError, OSError, ValueError) as exc: - raise UpdateFailed(exc) from exc + return await self._api.async_get_bytes_total() - return _get_dict(SENSORS_BYTES, datas) - - async def _get_rates(self) -> dict[str, Any]: + @handle_errors_and_zip((IndexError, OSError, ValueError), SENSORS_RATES) + async def _get_rates(self) -> Any: """Fetch rates information from the router.""" - try: - rates = await self._api.async_get_current_transfer_rates() - except (IndexError, OSError, ValueError) as exc: - raise UpdateFailed(exc) from exc + return await self._api.async_get_current_transfer_rates() - return _get_dict(SENSORS_RATES, rates) - - async def _get_load_avg(self) -> dict[str, Any]: + @handle_errors_and_zip((IndexError, OSError, ValueError), SENSORS_LOAD_AVG) + async def _get_load_avg(self) -> Any: """Fetch load average information from the router.""" - try: - avg = await self._api.async_get_loadavg() - except (IndexError, OSError, ValueError) as exc: - raise UpdateFailed(exc) from exc + return await self._api.async_get_loadavg() - return _get_dict(SENSORS_LOAD_AVG, avg) - - async def _get_temperatures(self) -> dict[str, Any]: + @handle_errors_and_zip((OSError, ValueError), None) + async def _get_temperatures(self) -> Any: """Fetch temperatures information from the router.""" - try: - temperatures: dict[str, Any] = await self._api.async_get_temperature() - except (OSError, ValueError) as exc: - raise UpdateFailed(exc) from exc + return await self._api.async_get_temperature() - return temperatures + +class AsusWrtHttpBridge(AsusWrtBridge): + """The Bridge that use HTTP library.""" + + def __init__(self, conf: dict[str, Any], session: ClientSession) -> None: + """Initialize Bridge that use HTTP library.""" + super().__init__(conf[CONF_HOST]) + self._api: AsusWrtHttp = self._get_api(conf, session) + + @staticmethod + def _get_api(conf: dict[str, Any], session: ClientSession) -> AsusWrtHttp: + """Get the AsusWrtHttp API.""" + return AsusWrtHttp( + conf[CONF_HOST], + conf[CONF_USERNAME], + conf.get(CONF_PASSWORD, ""), + use_https=conf[CONF_PROTOCOL] == PROTOCOL_HTTPS, + port=conf.get(CONF_PORT), + session=session, + ) + + @property + def is_connected(self) -> bool: + """Get connected status.""" + return cast(bool, self._api.is_connected) + + async def async_connect(self) -> None: + """Connect to the device.""" + await self._api.async_connect() + + # get main router properties + if mac := self._api.mac: + self._label_mac = format_mac(mac) + self._firmware = self._api.firmware + self._model = self._api.model + + async def async_disconnect(self) -> None: + """Disconnect to the device.""" + await self._api.async_disconnect() + + async def async_get_connected_devices(self) -> dict[str, WrtDevice]: + """Get list of connected devices.""" + try: + api_devices = await self._api.async_get_connected_devices() + except AsusWrtError as exc: + raise UpdateFailed(exc) from exc + return { + format_mac(mac): WrtDevice(dev.ip, dev.name, dev.node) + for mac, dev in api_devices.items() + } + + async def async_get_available_sensors(self) -> dict[str, dict[str, Any]]: + """Return a dictionary of available sensors for this bridge.""" + sensors_temperatures = await self._get_available_temperature_sensors() + sensors_types = { + SENSORS_TYPE_BYTES: { + KEY_SENSORS: SENSORS_BYTES, + KEY_METHOD: self._get_bytes, + }, + SENSORS_TYPE_LOAD_AVG: { + KEY_SENSORS: SENSORS_LOAD_AVG, + KEY_METHOD: self._get_load_avg, + }, + SENSORS_TYPE_RATES: { + KEY_SENSORS: SENSORS_RATES, + KEY_METHOD: self._get_rates, + }, + SENSORS_TYPE_TEMPERATURES: { + KEY_SENSORS: sensors_temperatures, + KEY_METHOD: self._get_temperatures, + }, + } + return sensors_types + + async def _get_available_temperature_sensors(self) -> list[str]: + """Check which temperature information is available on the router.""" + try: + available_temps = await self._api.async_get_temperatures() + available_sensors = [ + t for t in SENSORS_TEMPERATURES if t in available_temps + ] + except AsusWrtError as exc: + _LOGGER.warning( + ( + "Failed checking temperature sensor availability for ASUS router" + " %s. Exception: %s" + ), + self.host, + exc, + ) + return [] + return available_sensors + + @handle_errors_and_zip(AsusWrtError, SENSORS_BYTES) + async def _get_bytes(self) -> Any: + """Fetch byte information from the router.""" + return await self._api.async_get_traffic_bytes() + + @handle_errors_and_zip(AsusWrtError, SENSORS_RATES) + async def _get_rates(self) -> Any: + """Fetch rates information from the router.""" + return await self._api.async_get_traffic_rates() + + @handle_errors_and_zip(AsusWrtError, SENSORS_LOAD_AVG) + async def _get_load_avg(self) -> Any: + """Fetch cpu load avg information from the router.""" + return await self._api.async_get_loadavg() + + @handle_errors_and_zip(AsusWrtError, None) + async def _get_temperatures(self) -> Any: + """Fetch temperatures information from the router.""" + return await self._api.async_get_temperatures() diff --git a/homeassistant/components/asuswrt/config_flow.py b/homeassistant/components/asuswrt/config_flow.py index 56569d4f23b..047e9b549d8 100644 --- a/homeassistant/components/asuswrt/config_flow.py +++ b/homeassistant/components/asuswrt/config_flow.py @@ -7,6 +7,7 @@ import os import socket from typing import Any, cast +from pyasuswrt import AsusWrtError import voluptuous as vol from homeassistant.components.device_tracker import ( @@ -15,6 +16,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ( + CONF_BASE, CONF_HOST, CONF_MODE, CONF_PASSWORD, @@ -30,6 +32,7 @@ from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, SchemaOptionsFlowHandler, ) +from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig from .bridge import AsusWrtBridge from .const import ( @@ -44,11 +47,21 @@ from .const import ( DOMAIN, MODE_AP, MODE_ROUTER, + PROTOCOL_HTTP, + PROTOCOL_HTTPS, PROTOCOL_SSH, PROTOCOL_TELNET, ) -LABEL_MAC = "LABEL_MAC" +ALLOWED_PROTOCOL = [ + PROTOCOL_HTTPS, + PROTOCOL_SSH, + PROTOCOL_HTTP, + PROTOCOL_TELNET, +] + +PASS_KEY = "pass_key" +PASS_KEY_MSG = "Only provide password or SSH key file" RESULT_CONN_ERROR = "cannot_connect" RESULT_SUCCESS = "success" @@ -56,14 +69,20 @@ RESULT_UNKNOWN = "unknown" _LOGGER = logging.getLogger(__name__) +LEGACY_SCHEMA = vol.Schema( + { + vol.Required(CONF_MODE, default=MODE_ROUTER): vol.In( + {MODE_ROUTER: "Router", MODE_AP: "Access Point"} + ), + } +) + OPTIONS_SCHEMA = vol.Schema( { vol.Optional( CONF_CONSIDER_HOME, default=DEFAULT_CONSIDER_HOME.total_seconds() ): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=900)), vol.Optional(CONF_TRACK_UNKNOWN, default=DEFAULT_TRACK_UNKNOWN): bool, - vol.Required(CONF_INTERFACE, default=DEFAULT_INTERFACE): str, - vol.Required(CONF_DNSMASQ, default=DEFAULT_DNSMASQ): str, } ) @@ -72,12 +91,22 @@ async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: """Get options schema.""" options_flow: SchemaOptionsFlowHandler options_flow = cast(SchemaOptionsFlowHandler, handler.parent_handler) - if options_flow.config_entry.data[CONF_MODE] == MODE_AP: - return OPTIONS_SCHEMA.extend( + used_protocol = options_flow.config_entry.data[CONF_PROTOCOL] + if used_protocol in [PROTOCOL_SSH, PROTOCOL_TELNET]: + data_schema = OPTIONS_SCHEMA.extend( { - vol.Optional(CONF_REQUIRE_IP, default=True): bool, + vol.Required(CONF_INTERFACE, default=DEFAULT_INTERFACE): str, + vol.Required(CONF_DNSMASQ, default=DEFAULT_DNSMASQ): str, } ) + if options_flow.config_entry.data[CONF_MODE] == MODE_AP: + return data_schema.extend( + { + vol.Optional(CONF_REQUIRE_IP, default=True): bool, + } + ) + return data_schema + return OPTIONS_SCHEMA @@ -101,45 +130,47 @@ def _get_ip(host: str) -> str | None: class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN): - """Handle a config flow.""" + """Handle a config flow for AsusWRT.""" VERSION = 1 + def __init__(self) -> None: + """Initialize the AsusWrt config flow.""" + self._config_data: dict[str, Any] = {} + @callback - def _show_setup_form( - self, - user_input: dict[str, Any] | None = None, - errors: dict[str, str] | None = None, - ) -> FlowResult: + def _show_setup_form(self, error: str | None = None) -> FlowResult: """Show the setup form to the user.""" - if user_input is None: - user_input = {} + user_input = self._config_data - adv_schema = {} - conf_password = vol.Required(CONF_PASSWORD) if self.show_advanced_options: - conf_password = vol.Optional(CONF_PASSWORD) - adv_schema[vol.Optional(CONF_PORT)] = cv.port - adv_schema[vol.Optional(CONF_SSH_KEY)] = str + add_schema = { + vol.Exclusive(CONF_PASSWORD, PASS_KEY, PASS_KEY_MSG): str, + vol.Optional(CONF_PORT): cv.port, + vol.Exclusive(CONF_SSH_KEY, PASS_KEY, PASS_KEY_MSG): str, + } + else: + add_schema = {vol.Required(CONF_PASSWORD): str} schema = { vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str, vol.Required(CONF_USERNAME, default=user_input.get(CONF_USERNAME, "")): str, - conf_password: str, - vol.Required(CONF_PROTOCOL, default=PROTOCOL_SSH): vol.In( - {PROTOCOL_SSH: "SSH", PROTOCOL_TELNET: "Telnet"} - ), - **adv_schema, - vol.Required(CONF_MODE, default=MODE_ROUTER): vol.In( - {MODE_ROUTER: "Router", MODE_AP: "Access Point"} + **add_schema, + vol.Required( + CONF_PROTOCOL, + default=user_input.get(CONF_PROTOCOL, PROTOCOL_HTTPS), + ): SelectSelector( + SelectSelectorConfig( + options=ALLOWED_PROTOCOL, translation_key="protocols" + ) ), } return self.async_show_form( step_id="user", data_schema=vol.Schema(schema), - errors=errors or {}, + errors={CONF_BASE: error} if error else None, ) async def _async_check_connection( @@ -147,25 +178,49 @@ class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN): ) -> tuple[str, str | None]: """Attempt to connect the AsusWrt router.""" + api: AsusWrtBridge host: str = user_input[CONF_HOST] - api = AsusWrtBridge.get_bridge(self.hass, user_input) + protocol = user_input[CONF_PROTOCOL] + error: str | None = None + + conf = {**user_input, CONF_MODE: MODE_ROUTER} + api = AsusWrtBridge.get_bridge(self.hass, conf) try: await api.async_connect() - except OSError: - _LOGGER.error("Error connecting to the AsusWrt router at %s", host) - return RESULT_CONN_ERROR, None + except (AsusWrtError, OSError): + _LOGGER.error( + "Error connecting to the AsusWrt router at %s using protocol %s", + host, + protocol, + ) + error = RESULT_CONN_ERROR except Exception: # pylint: disable=broad-except _LOGGER.exception( - "Unknown error connecting with AsusWrt router at %s", host + "Unknown error connecting with AsusWrt router at %s using protocol %s", + host, + protocol, ) - return RESULT_UNKNOWN, None + error = RESULT_UNKNOWN - if not api.is_connected: - _LOGGER.error("Error connecting to the AsusWrt router at %s", host) - return RESULT_CONN_ERROR, None + if error is None: + if not api.is_connected: + _LOGGER.error( + "Error connecting to the AsusWrt router at %s using protocol %s", + host, + protocol, + ) + error = RESULT_CONN_ERROR + if error is not None: + return error, None + + _LOGGER.info( + "Successfully connected to the AsusWrt router at %s using protocol %s", + host, + protocol, + ) unique_id = api.label_mac await api.async_disconnect() @@ -182,51 +237,59 @@ class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="no_unique_id") if user_input is None: - return self._show_setup_form(user_input) - - errors: dict[str, str] = {} - host: str = user_input[CONF_HOST] + return self._show_setup_form() + self._config_data = user_input pwd: str | None = user_input.get(CONF_PASSWORD) ssh: str | None = user_input.get(CONF_SSH_KEY) + protocol: str = user_input[CONF_PROTOCOL] + if not pwd and protocol != PROTOCOL_SSH: + return self._show_setup_form(error="pwd_required") if not (pwd or ssh): - errors["base"] = "pwd_or_ssh" - elif ssh: - if pwd: - errors["base"] = "pwd_and_ssh" + return self._show_setup_form(error="pwd_or_ssh") + if ssh and not await self.hass.async_add_executor_job(_is_file, ssh): + return self._show_setup_form(error="ssh_not_file") + + host: str = user_input[CONF_HOST] + if not await self.hass.async_add_executor_job(_get_ip, host): + return self._show_setup_form(error="invalid_host") + + result, unique_id = await self._async_check_connection(user_input) + if result == RESULT_SUCCESS: + if unique_id: + await self.async_set_unique_id(unique_id) + # we allow to configure a single instance without unique id + elif self._async_current_entries(): + return self.async_abort(reason="invalid_unique_id") else: - isfile = await self.hass.async_add_executor_job(_is_file, ssh) - if not isfile: - errors["base"] = "ssh_not_file" - - if not errors: - ip_address = await self.hass.async_add_executor_job(_get_ip, host) - if not ip_address: - errors["base"] = "invalid_host" - - if not errors: - result, unique_id = await self._async_check_connection(user_input) - if result == RESULT_SUCCESS: - if unique_id: - await self.async_set_unique_id(unique_id) - # we allow configure a single instance without unique id - elif self._async_current_entries(): - return self.async_abort(reason="invalid_unique_id") - else: - _LOGGER.warning( - "This device does not provide a valid Unique ID." - " Configuration of multiple instance will not be possible" - ) - - return self.async_create_entry( - title=host, - data=user_input, + _LOGGER.warning( + "This device does not provide a valid Unique ID." + " Configuration of multiple instance will not be possible" ) - errors["base"] = result + if protocol in [PROTOCOL_SSH, PROTOCOL_TELNET]: + return await self.async_step_legacy() + return await self._async_save_entry() - return self._show_setup_form(user_input, errors) + return self._show_setup_form(error=result) + + async def async_step_legacy( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow for legacy settings.""" + if user_input is None: + return self.async_show_form(step_id="legacy", data_schema=LEGACY_SCHEMA) + + self._config_data.update(user_input) + return await self._async_save_entry() + + async def _async_save_entry(self) -> FlowResult: + """Save entry data if unique id is valid.""" + return self.async_create_entry( + title=self._config_data[CONF_HOST], + data=self._config_data, + ) @staticmethod @callback diff --git a/homeassistant/components/asuswrt/const.py b/homeassistant/components/asuswrt/const.py index 1733d4c09c3..a4cd6cde94c 100644 --- a/homeassistant/components/asuswrt/const.py +++ b/homeassistant/components/asuswrt/const.py @@ -20,6 +20,8 @@ KEY_SENSORS = "sensors" MODE_AP = "ap" MODE_ROUTER = "router" +PROTOCOL_HTTP = "http" +PROTOCOL_HTTPS = "https" PROTOCOL_SSH = "ssh" PROTOCOL_TELNET = "telnet" diff --git a/homeassistant/components/asuswrt/diagnostics.py b/homeassistant/components/asuswrt/diagnostics.py index 61de4c866db..0a3cc809c32 100644 --- a/homeassistant/components/asuswrt/diagnostics.py +++ b/homeassistant/components/asuswrt/diagnostics.py @@ -36,7 +36,7 @@ async def async_get_config_entry_diagnostics( device_registry = dr.async_get(hass) entity_registry = er.async_get(hass) hass_device = device_registry.async_get_device( - identifiers=router.device_info["identifiers"] + identifiers=router.device_info[ATTR_IDENTIFIERS] ) if not hass_device: return data diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json index 39f88fb96fe..9ed09cee67f 100644 --- a/homeassistant/components/asuswrt/manifest.json +++ b/homeassistant/components/asuswrt/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioasuswrt", "asyncssh"], - "requirements": ["aioasuswrt==1.4.0"] + "requirements": ["aioasuswrt==1.4.0", "pyasuswrt==0.1.20"] } diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index c6fe651d292..927eef572f7 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -6,6 +6,8 @@ from datetime import datetime, timedelta import logging from typing import Any +from pyasuswrt import AsusWrtError + from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, @@ -219,7 +221,7 @@ class AsusWrtRouter: """Set up a AsusWrt router.""" try: await self._api.async_connect() - except OSError as exc: + except (AsusWrtError, OSError) as exc: raise ConfigEntryNotReady from exc if not self._api.is_connected: raise ConfigEntryNotReady diff --git a/homeassistant/components/asuswrt/strings.json b/homeassistant/components/asuswrt/strings.json index 52b9f919434..8a3207ec7cb 100644 --- a/homeassistant/components/asuswrt/strings.json +++ b/homeassistant/components/asuswrt/strings.json @@ -2,25 +2,31 @@ "config": { "step": { "user": { - "title": "AsusWRT", "description": "Set required parameter to connect to your router", "data": { "host": "[%key:common::config_flow::data::host%]", - "name": "[%key:common::config_flow::data::name%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "ssh_key": "Path to your SSH key file (instead of password)", "protocol": "Communication protocol to use", - "port": "Port (leave empty for protocol default)", - "mode": "[%key:common::config_flow::data::mode%]" + "port": "Port (leave empty for protocol default)" + }, + "data_description": { + "host": "The hostname or IP address of your ASUSWRT router." + } + }, + "legacy": { + "description": "Set required parameters to connect to your router", + "data": { + "mode": "Router operating mode" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_host": "[%key:common::config_flow::error::invalid_host%]", - "pwd_and_ssh": "Only provide password or SSH key file", "pwd_or_ssh": "Please provide password or SSH key file", + "pwd_required": "Password is required for selected protocol", "ssh_not_file": "SSH key file not found", "unknown": "[%key:common::config_flow::error::unknown%]" }, @@ -32,7 +38,6 @@ "options": { "step": { "init": { - "title": "AsusWRT Options", "data": { "consider_home": "Seconds to wait before considering a device away", "track_unknown": "Track unknown / unnamed devices", @@ -79,5 +84,15 @@ "name": "CPU Temperature" } } + }, + "selector": { + "protocols": { + "options": { + "https": "HTTPS", + "http": "HTTP", + "ssh": "SSH", + "telnet": "Telnet" + } + } } } diff --git a/homeassistant/components/atag/strings.json b/homeassistant/components/atag/strings.json index 39ed972524d..82070c0209f 100644 --- a/homeassistant/components/atag/strings.json +++ b/homeassistant/components/atag/strings.json @@ -2,10 +2,13 @@ "config": { "step": { "user": { - "title": "Connect to the device", + "description": "Connect to the device", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of the Atag device." } } }, diff --git a/homeassistant/components/august/entity.py b/homeassistant/components/august/entity.py index 47f3b8be74f..d149e035ac4 100644 --- a/homeassistant/components/august/entity.py +++ b/homeassistant/components/august/entity.py @@ -2,10 +2,12 @@ from abc import abstractmethod from yalexs.doorbell import Doorbell -from yalexs.lock import Lock +from yalexs.lock import Lock, LockDetail from yalexs.util import get_configuration_url +from homeassistant.const import ATTR_CONNECTIONS from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -26,15 +28,18 @@ class AugustEntityMixin(Entity): super().__init__() self._data = data self._device = device + detail = self._detail self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._device_id)}, manufacturer=MANUFACTURER, - model=self._detail.model, + model=detail.model, name=device.device_name, - sw_version=self._detail.firmware_version, + sw_version=detail.firmware_version, suggested_area=_remove_device_types(device.device_name, DEVICE_TYPES), configuration_url=get_configuration_url(data.brand), ) + if isinstance(detail, LockDetail) and (mac := detail.mac_address): + self._attr_device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_BLUETOOTH, mac)} @property def _device_id(self): diff --git a/homeassistant/components/aurora_abb_powerone/__init__.py b/homeassistant/components/aurora_abb_powerone/__init__.py index b5dc236dfa2..43e3bd2ad5c 100644 --- a/homeassistant/components/aurora_abb_powerone/__init__.py +++ b/homeassistant/components/aurora_abb_powerone/__init__.py @@ -12,13 +12,14 @@ import logging -from aurorapy.client import AuroraSerialClient +from aurorapy.client import AuroraError, AuroraSerialClient, AuroraTimeoutError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_PORT, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN +from .const import DOMAIN, SCAN_INTERVAL PLATFORMS = [Platform.SENSOR] @@ -30,8 +31,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: comport = entry.data[CONF_PORT] address = entry.data[CONF_ADDRESS] - ser_client = AuroraSerialClient(address, comport, parity="N", timeout=1) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ser_client + coordinator = AuroraAbbDataUpdateCoordinator(hass, comport, address) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -47,3 +50,58 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]): + """Class to manage fetching AuroraAbbPowerone data.""" + + def __init__(self, hass: HomeAssistant, comport: str, address: int) -> None: + """Initialize the data update coordinator.""" + self.available_prev = False + self.available = False + self.client = AuroraSerialClient(address, comport, parity="N", timeout=1) + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + + def _update_data(self) -> dict[str, float]: + """Fetch new state data for the sensor. + + This is the only function that should fetch new data for Home Assistant. + """ + data: dict[str, float] = {} + self.available_prev = self.available + try: + self.client.connect() + + # read ADC channel 3 (grid power output) + power_watts = self.client.measure(3, True) + temperature_c = self.client.measure(21) + energy_wh = self.client.cumulated_energy(5) + except AuroraTimeoutError: + self.available = False + _LOGGER.debug("No response from inverter (could be dark)") + except AuroraError as error: + self.available = False + raise error + else: + data["instantaneouspower"] = round(power_watts, 1) + data["temp"] = round(temperature_c, 1) + data["totalenergy"] = round(energy_wh / 1000, 2) + self.available = True + + finally: + if self.available != self.available_prev: + if self.available: + _LOGGER.info("Communication with %s back online", self.name) + else: + _LOGGER.warning( + "Communication with %s lost", + self.name, + ) + if self.client.serline.isOpen(): + self.client.close() + + return data + + async def _async_update_data(self) -> dict[str, float]: + """Update inverter data in the executor.""" + return await self.hass.async_add_executor_job(self._update_data) diff --git a/homeassistant/components/aurora_abb_powerone/aurora_device.py b/homeassistant/components/aurora_abb_powerone/aurora_device.py deleted file mode 100644 index e9ca9e47121..00000000000 --- a/homeassistant/components/aurora_abb_powerone/aurora_device.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Top level class for AuroraABBPowerOneSolarPV inverters and sensors.""" -from __future__ import annotations - -from collections.abc import Mapping -import logging -from typing import Any - -from aurorapy.client import AuroraSerialClient - -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity - -from .const import ( - ATTR_DEVICE_NAME, - ATTR_FIRMWARE, - ATTR_MODEL, - ATTR_SERIAL_NUMBER, - DEFAULT_DEVICE_NAME, - DOMAIN, - MANUFACTURER, -) - -_LOGGER = logging.getLogger(__name__) - - -class AuroraEntity(Entity): - """Representation of an Aurora ABB PowerOne device.""" - - def __init__(self, client: AuroraSerialClient, data: Mapping[str, Any]) -> None: - """Initialise the basic device.""" - self._data = data - self.type = "device" - self.client = client - self._available = True - - @property - def unique_id(self) -> str | None: - """Return the unique id for this device.""" - if (serial := self._data.get(ATTR_SERIAL_NUMBER)) is None: - return None - return f"{serial}_{self.entity_description.key}" - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes.""" - return DeviceInfo( - identifiers={(DOMAIN, self._data[ATTR_SERIAL_NUMBER])}, - manufacturer=MANUFACTURER, - model=self._data[ATTR_MODEL], - name=self._data.get(ATTR_DEVICE_NAME, DEFAULT_DEVICE_NAME), - sw_version=self._data[ATTR_FIRMWARE], - ) diff --git a/homeassistant/components/aurora_abb_powerone/const.py b/homeassistant/components/aurora_abb_powerone/const.py index 3711dd6d800..d1266a838c3 100644 --- a/homeassistant/components/aurora_abb_powerone/const.py +++ b/homeassistant/components/aurora_abb_powerone/const.py @@ -1,5 +1,7 @@ """Constants for the Aurora ABB PowerOne integration.""" +from datetime import timedelta + DOMAIN = "aurora_abb_powerone" # Min max addresses and default according to here: @@ -8,6 +10,7 @@ DOMAIN = "aurora_abb_powerone" MIN_ADDRESS = 2 MAX_ADDRESS = 63 DEFAULT_ADDRESS = 2 +SCAN_INTERVAL = timedelta(seconds=30) DEFAULT_INTEGRATION_TITLE = "PhotoVoltaic Inverters" DEFAULT_DEVICE_NAME = "Solar Inverter" diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py index 55f3be5d6db..0e7d0c06a4e 100644 --- a/homeassistant/components/aurora_abb_powerone/sensor.py +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -5,8 +5,6 @@ from collections.abc import Mapping import logging from typing import Any -from aurorapy.client import AuroraError, AuroraSerialClient, AuroraTimeoutError - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -21,10 +19,21 @@ from homeassistant.const import ( 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 homeassistant.helpers.update_coordinator import CoordinatorEntity -from .aurora_device import AuroraEntity -from .const import DOMAIN +from . import AuroraAbbDataUpdateCoordinator +from .const import ( + ATTR_DEVICE_NAME, + ATTR_FIRMWARE, + ATTR_MODEL, + ATTR_SERIAL_NUMBER, + DEFAULT_DEVICE_NAME, + DOMAIN, + MANUFACTURER, +) _LOGGER = logging.getLogger(__name__) @@ -61,70 +70,40 @@ async def async_setup_entry( """Set up aurora_abb_powerone sensor based on a config entry.""" entities = [] - client = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id] data = config_entry.data for sens in SENSOR_TYPES: - entities.append(AuroraSensor(client, data, sens)) + entities.append(AuroraSensor(coordinator, data, sens)) _LOGGER.debug("async_setup_entry adding %d entities", len(entities)) async_add_entities(entities, True) -class AuroraSensor(AuroraEntity, SensorEntity): - """Representation of a Sensor on a Aurora ABB PowerOne Solar inverter.""" +class AuroraSensor(CoordinatorEntity[AuroraAbbDataUpdateCoordinator], SensorEntity): + """Representation of a Sensor on an Aurora ABB PowerOne Solar inverter.""" _attr_has_entity_name = True def __init__( self, - client: AuroraSerialClient, + coordinator: AuroraAbbDataUpdateCoordinator, data: Mapping[str, Any], entity_description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" - super().__init__(client, data) + super().__init__(coordinator) self.entity_description = entity_description - self.available_prev = True + self._attr_unique_id = f"{data[ATTR_SERIAL_NUMBER]}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, data[ATTR_SERIAL_NUMBER])}, + manufacturer=MANUFACTURER, + model=data[ATTR_MODEL], + name=data.get(ATTR_DEVICE_NAME, DEFAULT_DEVICE_NAME), + sw_version=data[ATTR_FIRMWARE], + ) - def update(self) -> None: - """Fetch new state data for the sensor. - - This is the only method that should fetch new data for Home Assistant. - """ - try: - self.available_prev = self._attr_available - self.client.connect() - if self.entity_description.key == "instantaneouspower": - # read ADC channel 3 (grid power output) - power_watts = self.client.measure(3, True) - self._attr_native_value = round(power_watts, 1) - elif self.entity_description.key == "temp": - temperature_c = self.client.measure(21) - self._attr_native_value = round(temperature_c, 1) - elif self.entity_description.key == "totalenergy": - energy_wh = self.client.cumulated_energy(5) - self._attr_native_value = round(energy_wh / 1000, 2) - self._attr_available = True - - except AuroraTimeoutError: - self._attr_state = None - self._attr_native_value = None - self._attr_available = False - _LOGGER.debug("No response from inverter (could be dark)") - except AuroraError as error: - self._attr_state = None - self._attr_native_value = None - self._attr_available = False - raise error - finally: - if self._attr_available != self.available_prev: - if self._attr_available: - _LOGGER.info("Communication with %s back online", self.name) - else: - _LOGGER.warning( - "Communication with %s lost", - self.name, - ) - if self.client.serline.isOpen(): - self.client.close() + @property + def native_value(self) -> StateType: + """Get the value of the sensor from previously collected data.""" + return self.coordinator.data.get(self.entity_description.key) diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index 364f5242377..96255f59c7b 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -71,14 +71,14 @@ from __future__ import annotations from collections.abc import Callable from http import HTTPStatus from ipaddress import ip_address -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from aiohttp import web import voluptuous as vol import voluptuous_serialize from homeassistant import data_entry_flow -from homeassistant.auth import AuthManagerFlowManager +from homeassistant.auth import AuthManagerFlowManager, InvalidAuthError from homeassistant.auth.models import Credentials from homeassistant.components import onboarding from homeassistant.components.http.auth import async_user_not_allowed_do_auth @@ -90,10 +90,16 @@ from homeassistant.components.http.ban import ( from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant +from homeassistant.helpers.network import is_cloud_connection +from homeassistant.util.network import is_local from . import indieauth if TYPE_CHECKING: + from homeassistant.auth.providers.trusted_networks import ( + TrustedNetworksAuthProvider, + ) + from . import StoreResultType @@ -146,12 +152,61 @@ class AuthProvidersView(HomeAssistantView): message_code="onboarding_required", ) - return self.json( - [ - {"name": provider.name, "id": provider.id, "type": provider.type} - for provider in hass.auth.auth_providers - ] - ) + try: + remote_address = ip_address(request.remote) # type: ignore[arg-type] + except ValueError: + return self.json_message( + message="Invalid remote IP", + status_code=HTTPStatus.BAD_REQUEST, + message_code="invalid_remote_ip", + ) + + cloud_connection = is_cloud_connection(hass) + + providers = [] + for provider in hass.auth.auth_providers: + additional_data = {} + + if provider.type == "trusted_networks": + if cloud_connection: + # Skip quickly as trusted networks are not available on cloud + continue + + try: + cast("TrustedNetworksAuthProvider", provider).async_validate_access( + remote_address + ) + except InvalidAuthError: + # Not a trusted network, so we don't expose that trusted_network authenticator is setup + continue + elif ( + provider.type == "homeassistant" + and not cloud_connection + and is_local(remote_address) + and "person" in hass.config.components + ): + # We are local, return user id and username + users = await provider.store.async_get_users() + additional_data["users"] = { + user.id: credentials.data["username"] + for user in users + for credentials in user.credentials + if ( + credentials.auth_provider_type == provider.type + and credentials.auth_provider_id == provider.id + ) + } + + providers.append( + { + "name": provider.name, + "id": provider.id, + "type": provider.type, + **additional_data, + } + ) + + return self.json(providers) def _prepare_result_json( @@ -235,7 +290,7 @@ class LoginFlowBaseView(HomeAssistantView): f"Login blocked: {user_access_error}", HTTPStatus.FORBIDDEN ) - await process_success_login(request) + process_success_login(request) result["result"] = self._store_result(client_id, result_obj) return self.json(result) diff --git a/homeassistant/components/auth/strings.json b/homeassistant/components/auth/strings.json index d386bb7a488..0dd3ee64cdf 100644 --- a/homeassistant/components/auth/strings.json +++ b/homeassistant/components/auth/strings.json @@ -31,5 +31,11 @@ "invalid_code": "Invalid code, please try again." } } + }, + "issues": { + "deprecated_legacy_api_password": { + "title": "The legacy API password is deprecated", + "description": "The legacy API password authentication provider is deprecated and will be removed. Please remove it from your YAML configuration and use the default Home Assistant authentication provider instead." + } } } diff --git a/homeassistant/components/automation/helpers.py b/homeassistant/components/automation/helpers.py index 7c2efc17bf4..a7c329a544a 100644 --- a/homeassistant/components/automation/helpers.py +++ b/homeassistant/components/automation/helpers.py @@ -1,5 +1,6 @@ """Helpers for automation integration.""" from homeassistant.components import blueprint +from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.singleton import singleton @@ -15,8 +16,17 @@ def _blueprint_in_use(hass: HomeAssistant, blueprint_path: str) -> bool: return len(automations_with_blueprint(hass, blueprint_path)) > 0 +async def _reload_blueprint_automations( + hass: HomeAssistant, blueprint_path: str +) -> None: + """Reload all automations that rely on a specific blueprint.""" + await hass.services.async_call(DOMAIN, SERVICE_RELOAD) + + @singleton(DATA_BLUEPRINTS) @callback def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints: """Get automation blueprints.""" - return blueprint.DomainBlueprints(hass, DOMAIN, LOGGER, _blueprint_in_use) + return blueprint.DomainBlueprints( + hass, DOMAIN, LOGGER, _blueprint_in_use, _reload_blueprint_automations + ) diff --git a/homeassistant/components/axis/strings.json b/homeassistant/components/axis/strings.json index 47a25b542a7..8c302dba201 100644 --- a/homeassistant/components/axis/strings.json +++ b/homeassistant/components/axis/strings.json @@ -3,12 +3,16 @@ "flow_title": "{name} ({host})", "step": { "user": { - "title": "Set up Axis device", + "description": "Set up an Axis device", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of the Axis device.", + "username": "The user name you set up on your Axis device. It is recommended to create a user specifically for Home Assistant." } } }, diff --git a/homeassistant/components/baf/fan.py b/homeassistant/components/baf/fan.py index 059603fc589..e2d1c5fcb3a 100644 --- a/homeassistant/components/baf/fan.py +++ b/homeassistant/components/baf/fan.py @@ -93,8 +93,6 @@ class BAFFan(BAFEntity, FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if preset_mode != PRESET_MODE_AUTO: - raise ValueError(f"Invalid preset mode: {preset_mode}") self._device.fan_mode = OffOnAuto.AUTO async def async_set_direction(self, direction: str) -> None: diff --git a/homeassistant/components/balboa/binary_sensor.py b/homeassistant/components/balboa/binary_sensor.py index 9f363746a8f..7462d051643 100644 --- a/homeassistant/components/balboa/binary_sensor.py +++ b/homeassistant/components/balboa/binary_sensor.py @@ -47,31 +47,27 @@ class BalboaBinarySensorEntityDescription( ): """A class that describes Balboa binary sensor entities.""" - # BalboaBinarySensorEntity does not support UNDEFINED or None, - # restrict the type to str. - name: str = "" - FILTER_CYCLE_ICONS = ("mdi:sync", "mdi:sync-off") BINARY_SENSOR_DESCRIPTIONS = ( BalboaBinarySensorEntityDescription( - key="filter_cycle_1", - name="Filter1", + key="Filter1", + translation_key="filter_1", device_class=BinarySensorDeviceClass.RUNNING, is_on_fn=lambda spa: spa.filter_cycle_1_running, on_off_icons=FILTER_CYCLE_ICONS, ), BalboaBinarySensorEntityDescription( - key="filter_cycle_2", - name="Filter2", + key="Filter2", + translation_key="filter_2", device_class=BinarySensorDeviceClass.RUNNING, is_on_fn=lambda spa: spa.filter_cycle_2_running, on_off_icons=FILTER_CYCLE_ICONS, ), ) CIRCULATION_PUMP_DESCRIPTION = BalboaBinarySensorEntityDescription( - key="circulation_pump", - name="Circ Pump", + key="Circ Pump", + translation_key="circ_pump", device_class=BinarySensorDeviceClass.RUNNING, is_on_fn=lambda spa: (pump := spa.circulation_pump) is not None and pump.state > 0, on_off_icons=("mdi:pump", "mdi:pump-off"), @@ -87,7 +83,7 @@ class BalboaBinarySensorEntity(BalboaEntity, BinarySensorEntity): self, spa: SpaClient, description: BalboaBinarySensorEntityDescription ) -> None: """Initialize a Balboa binary sensor entity.""" - super().__init__(spa, description.name) + super().__init__(spa, description.key) self.entity_description = description @property diff --git a/homeassistant/components/balboa/climate.py b/homeassistant/components/balboa/climate.py index 0d0fa9bd179..d213a8fd2e8 100644 --- a/homeassistant/components/balboa/climate.py +++ b/homeassistant/components/balboa/climate.py @@ -59,6 +59,7 @@ class BalboaClimateEntity(BalboaEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_translation_key = DOMAIN + _attr_name = None def __init__(self, client: SpaClient) -> None: """Initialize the climate entity.""" diff --git a/homeassistant/components/balboa/entity.py b/homeassistant/components/balboa/entity.py index 3b4f7d08fff..e02579658da 100644 --- a/homeassistant/components/balboa/entity.py +++ b/homeassistant/components/balboa/entity.py @@ -15,12 +15,11 @@ class BalboaEntity(Entity): _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, client: SpaClient, name: str | None = None) -> None: + def __init__(self, client: SpaClient, key: str) -> None: """Initialize the control.""" mac = client.mac_address model = client.model - self._attr_unique_id = f'{model}-{name}-{mac.replace(":","")[-6:]}' - self._attr_name = name + self._attr_unique_id = f'{model}-{key}-{mac.replace(":","")[-6:]}' self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, mac)}, name=model, diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 214ccf8fbe1..101436c0f31 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -2,9 +2,12 @@ "config": { "step": { "user": { - "title": "Connect to the Balboa Wi-Fi device", + "description": "Connect to the Balboa Wi-Fi device", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Balboa Spa Wifi Device. For example, 192.168.1.58." } } }, @@ -26,6 +29,17 @@ } }, "entity": { + "binary_sensor": { + "filter_1": { + "name": "Filter cycle 1" + }, + "filter_2": { + "name": "Filter cycle 2" + }, + "circ_pump": { + "name": "Circulation pump" + } + }, "climate": { "balboa": { "state_attributes": { diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 79e20c6f571..a84cbc18756 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -10,8 +10,9 @@ from typing import Literal, final import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, @@ -190,6 +191,14 @@ class BinarySensorEntity(Entity): _attr_is_on: bool | None = None _attr_state: None = None + async def async_internal_added_to_hass(self) -> None: + """Call when the binary sensor entity is added to hass.""" + await super().async_internal_added_to_hass() + if self.entity_category == EntityCategory.CONFIG: + raise HomeAssistantError( + f"Entity {self.entity_id} cannot be added as the entity category is set to config" + ) + def _default_to_device_class_name(self) -> bool: """Return True if an unnamed entity should be named by its device class. diff --git a/homeassistant/components/blebox/config_flow.py b/homeassistant/components/blebox/config_flow.py index 31d1f6162d7..977e704eb98 100644 --- a/homeassistant/components/blebox/config_flow.py +++ b/homeassistant/components/blebox/config_flow.py @@ -112,7 +112,7 @@ class BleBoxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.device_config["name"] = product.name # Check if configured but IP changed since await self.async_set_unique_id(product.unique_id) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) self.context.update( { "title_placeholders": { diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index c6413dd4372..d83c2686563 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -21,17 +21,11 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType -from .const import ( - DEFAULT_SCAN_INTERVAL, - DOMAIN, - PLATFORMS, - SERVICE_REFRESH, - SERVICE_SAVE_RECENT_CLIPS, - SERVICE_SAVE_VIDEO, - SERVICE_SEND_PIN, -) +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS from .coordinator import BlinkUpdateCoordinator +from .services import setup_services _LOGGER = logging.getLogger(__name__) @@ -43,6 +37,8 @@ SERVICE_SAVE_RECENT_CLIPS_SCHEMA = vol.Schema( {vol.Required(CONF_NAME): cv.string, vol.Required(CONF_FILE_PATH): cv.string} ) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + async def _reauth_flow_wrapper(hass, data): """Reauth flow wrapper.""" @@ -75,6 +71,14 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Blink.""" + + setup_services(hass) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Blink via config entry.""" hass.data.setdefault(DOMAIN, {}) @@ -105,40 +109,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) - async def blink_refresh(event_time=None): - """Call blink to refresh info.""" - await coordinator.api.refresh(force_cache=True) - - async def async_save_video(call): - """Call save video service handler.""" - await async_handle_save_video_service(hass, entry, call) - - async def async_save_recent_clips(call): - """Call save recent clips service handler.""" - await async_handle_save_recent_clips_service(hass, entry, call) - - async def send_pin(call): - """Call blink to send new pin.""" - pin = call.data[CONF_PIN] - await coordinator.api.auth.send_auth_key( - hass.data[DOMAIN][entry.entry_id].api, - pin, - ) - - hass.services.async_register(DOMAIN, SERVICE_REFRESH, blink_refresh) - hass.services.async_register( - DOMAIN, SERVICE_SAVE_VIDEO, async_save_video, schema=SERVICE_SAVE_VIDEO_SCHEMA - ) - hass.services.async_register( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - async_save_recent_clips, - schema=SERVICE_SAVE_RECENT_CLIPS_SCHEMA, - ) - hass.services.async_register( - DOMAIN, SERVICE_SEND_PIN, send_pin, schema=SERVICE_SEND_PIN_SCHEMA - ) - return True @@ -158,13 +128,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Blink entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) - if not hass.data[DOMAIN]: - return True - - hass.services.async_remove(DOMAIN, SERVICE_REFRESH) - hass.services.async_remove(DOMAIN, SERVICE_SAVE_VIDEO) - hass.services.async_remove(DOMAIN, SERVICE_SEND_PIN) - return unload_ok @@ -172,37 +135,3 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" blink: Blink = hass.data[DOMAIN][entry.entry_id].api blink.refresh_rate = entry.options[CONF_SCAN_INTERVAL] - - -async def async_handle_save_video_service( - hass: HomeAssistant, entry: ConfigEntry, call -) -> None: - """Handle save video service calls.""" - camera_name = call.data[CONF_NAME] - video_path = call.data[CONF_FILENAME] - if not hass.config.is_allowed_path(video_path): - _LOGGER.error("Can't write %s, no access to path!", video_path) - return - all_cameras = hass.data[DOMAIN][entry.entry_id].api.cameras - if camera_name in all_cameras: - try: - await all_cameras[camera_name].video_to_file(video_path) - except OSError as err: - _LOGGER.error("Can't write image to file: %s", err) - - -async def async_handle_save_recent_clips_service( - hass: HomeAssistant, entry: ConfigEntry, call -) -> None: - """Save multiple recent clips to output directory.""" - camera_name = call.data[CONF_NAME] - clips_dir = call.data[CONF_FILE_PATH] - if not hass.config.is_allowed_path(clips_dir): - _LOGGER.error("Can't write to directory %s, no access to path!", clips_dir) - return - all_cameras = hass.data[DOMAIN][entry.entry_id].api.cameras - if camera_name in all_cameras: - try: - await all_cameras[camera_name].save_recent_clips(output_dir=clips_dir) - except OSError as err: - _LOGGER.error("Can't write recent clips to directory: %s", err) diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index d1fcb889fb8..8e0750d1373 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -58,7 +58,6 @@ class BlinkSyncModuleHA( """Initialize the alarm control panel.""" super().__init__(coordinator) self.api: Blink = coordinator.api - self._coordinator = coordinator self.sync = sync self._attr_unique_id: str = sync.serial self._attr_device_info = DeviceInfo( @@ -94,7 +93,7 @@ class BlinkSyncModuleHA( except asyncio.TimeoutError as er: raise HomeAssistantError("Blink failed to disarm camera") from er - await self._coordinator.async_refresh() + await self.coordinator.async_refresh() async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm command.""" @@ -104,5 +103,4 @@ class BlinkSyncModuleHA( except asyncio.TimeoutError as er: raise HomeAssistantError("Blink failed to arm camera away") from er - await self._coordinator.async_refresh() - self.async_write_ha_state() + await self.coordinator.async_refresh() diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index 47b45e2f4ec..8598868e2dc 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -32,9 +32,11 @@ BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, ), + # Camera Armed sensor is depreciated covered by switch and will be removed in 2023.6. BinarySensorEntityDescription( key=TYPE_CAMERA_ARMED, translation_key="camera_armed", + entity_registry_enabled_default=False, ), BinarySensorEntityDescription( key=TYPE_MOTION_DETECTED, @@ -47,6 +49,7 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the blink binary sensors.""" + coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id] entities = [ diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index c967ff59c8c..f507364f17f 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -32,6 +32,7 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up a Blink Camera.""" + coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id] entities = [ BlinkCamera(coordinator, name, camera) @@ -54,7 +55,6 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): """Initialize a camera.""" super().__init__(coordinator) Camera.__init__(self) - self._coordinator = coordinator self._camera = camera self._attr_unique_id = f"{camera.serial}-camera" self._attr_device_info = DeviceInfo( @@ -80,7 +80,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): raise HomeAssistantError("Blink failed to arm camera") from er self._camera.motion_enabled = True - await self._coordinator.async_refresh() + await self.coordinator.async_refresh() async def async_disable_motion_detection(self) -> None: """Disable motion detection for the camera.""" @@ -90,7 +90,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): raise HomeAssistantError("Blink failed to disarm camera") from er self._camera.motion_enabled = False - await self._coordinator.async_refresh() + await self.coordinator.async_refresh() @property def motion_detection_enabled(self) -> bool: @@ -106,7 +106,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): """Trigger camera to take a snapshot.""" with contextlib.suppress(asyncio.TimeoutError): await self._camera.snap_picture() - await self._coordinator.api.refresh() + await self.coordinator.api.refresh() self.async_write_ha_state() def camera_image( diff --git a/homeassistant/components/blink/const.py b/homeassistant/components/blink/const.py index 7de42a80efc..64b05e1ba27 100644 --- a/homeassistant/components/blink/const.py +++ b/homeassistant/components/blink/const.py @@ -7,6 +7,7 @@ DEVICE_ID = "Home Assistant" CONF_MIGRATE = "migrate" CONF_CAMERA = "camera" CONF_ALARM_CONTROL_PANEL = "alarm_control_panel" +CONF_DEVICE_ID = "device_id" DEFAULT_BRAND = "Blink" DEFAULT_ATTRIBUTION = "Data provided by immedia-semi.com" DEFAULT_SCAN_INTERVAL = 300 @@ -30,4 +31,5 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CAMERA, Platform.SENSOR, + Platform.SWITCH, ] diff --git a/homeassistant/components/blink/coordinator.py b/homeassistant/components/blink/coordinator.py index d3f7551e1b2..d53d23c4344 100644 --- a/homeassistant/components/blink/coordinator.py +++ b/homeassistant/components/blink/coordinator.py @@ -13,6 +13,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = 30 class BlinkUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): @@ -25,7 +26,7 @@ class BlinkUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): hass, _LOGGER, name=DOMAIN, - update_interval=timedelta(seconds=30), + update_interval=timedelta(seconds=SCAN_INTERVAL), ) async def _async_update_data(self) -> dict[str, Any]: diff --git a/homeassistant/components/blink/diagnostics.py b/homeassistant/components/blink/diagnostics.py new file mode 100644 index 00000000000..f69c1721bf1 --- /dev/null +++ b/homeassistant/components/blink/diagnostics.py @@ -0,0 +1,33 @@ +"""Diagnostics support for Blink.""" +from __future__ import annotations + +from typing import Any + +from blinkpy.blinkpy import Blink + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +TO_REDACT = {"serial", "macaddress", "username", "password", "token"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + config_entry: ConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + api: Blink = hass.data[DOMAIN][config_entry.entry_id].api + + data = { + camera.name: dict(camera.attributes.items()) + for _, camera in api.cameras.items() + } + + return { + "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), + "cameras": async_redact_data(data, TO_REDACT), + } diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index bb8fd4a5a51..db3ab91de11 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -1,7 +1,7 @@ { "domain": "blink", "name": "Blink", - "codeowners": ["@fronzbot"], + "codeowners": ["@fronzbot", "@mkmer"], "config_flow": true, "dhcp": [ { diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index 064ad9d04f2..74db76c421e 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -48,6 +48,7 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Initialize a Blink sensor.""" + coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id] entities = [ BlinkSensor(coordinator, camera, description) diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py new file mode 100644 index 00000000000..12ac0d3b859 --- /dev/null +++ b/homeassistant/components/blink/services.py @@ -0,0 +1,175 @@ +"""Services for the Blink integration.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import ( + ATTR_DEVICE_ID, + CONF_FILE_PATH, + CONF_FILENAME, + CONF_NAME, + CONF_PIN, +) +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.device_registry as dr + +from .const import ( + DOMAIN, + SERVICE_REFRESH, + SERVICE_SAVE_RECENT_CLIPS, + SERVICE_SAVE_VIDEO, + SERVICE_SEND_PIN, +) +from .coordinator import BlinkUpdateCoordinator + +SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_FILENAME): cv.string, + } +) +SERVICE_SEND_PIN_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_PIN): cv.string, + } +) +SERVICE_SAVE_RECENT_CLIPS_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_FILE_PATH): cv.string, + } +) + + +def setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Blink integration.""" + + def collect_coordinators( + device_ids: list[str], + ) -> list[BlinkUpdateCoordinator]: + config_entries: list[ConfigEntry] = [] + registry = dr.async_get(hass) + for target in device_ids: + device = registry.async_get(target) + if device: + device_entries: list[ConfigEntry] = [] + for entry_id in device.config_entries: + entry = hass.config_entries.async_get_entry(entry_id) + if entry and entry.domain == DOMAIN: + device_entries.append(entry) + if not device_entries: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_device", + translation_placeholders={"target": target, "domain": DOMAIN}, + ) + config_entries.extend(device_entries) + else: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={"target": target}, + ) + + coordinators: list[BlinkUpdateCoordinator] = [] + for config_entry in config_entries: + if config_entry.state != ConfigEntryState.LOADED: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": config_entry.title}, + ) + + coordinators.append(hass.data[DOMAIN][config_entry.entry_id]) + return coordinators + + async def async_handle_save_video_service(call: ServiceCall) -> None: + """Handle save video service calls.""" + camera_name = call.data[CONF_NAME] + video_path = call.data[CONF_FILENAME] + if not hass.config.is_allowed_path(video_path): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_path", + translation_placeholders={"target": video_path}, + ) + + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): + all_cameras = coordinator.api.cameras + if camera_name in all_cameras: + try: + await all_cameras[camera_name].video_to_file(video_path) + except OSError as err: + raise ServiceValidationError( + str(err), + translation_domain=DOMAIN, + translation_key="cant_write", + ) from err + + async def async_handle_save_recent_clips_service(call: ServiceCall) -> None: + """Save multiple recent clips to output directory.""" + camera_name = call.data[CONF_NAME] + clips_dir = call.data[CONF_FILE_PATH] + if not hass.config.is_allowed_path(clips_dir): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_path", + translation_placeholders={"target": clips_dir}, + ) + + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): + all_cameras = coordinator.api.cameras + if camera_name in all_cameras: + try: + await all_cameras[camera_name].save_recent_clips( + output_dir=clips_dir + ) + except OSError as err: + raise ServiceValidationError( + str(err), + translation_domain=DOMAIN, + translation_key="cant_write", + ) from err + + async def send_pin(call: ServiceCall): + """Call blink to send new pin.""" + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): + await coordinator.api.auth.send_auth_key( + coordinator.api, + call.data[CONF_PIN], + ) + + async def blink_refresh(call: ServiceCall): + """Call blink to refresh info.""" + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): + await coordinator.api.refresh(force_cache=True) + + # Register all the above services + service_mapping = [ + (blink_refresh, SERVICE_REFRESH, None), + ( + async_handle_save_video_service, + SERVICE_SAVE_VIDEO, + SERVICE_SAVE_VIDEO_SCHEMA, + ), + ( + async_handle_save_recent_clips_service, + SERVICE_SAVE_RECENT_CLIPS, + SERVICE_SAVE_RECENT_CLIPS_SCHEMA, + ), + (send_pin, SERVICE_SEND_PIN, SERVICE_SEND_PIN_SCHEMA), + ] + + for service_handler, service_name, schema in service_mapping: + hass.services.async_register( + DOMAIN, + service_name, + service_handler, + schema=schema, + ) diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index 85556bbcd5a..f47f72acb9c 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -47,6 +47,11 @@ "camera_armed": { "name": "Camera armed" } + }, + "switch": { + "camera_motion": { + "name": "Camera motion detection" + } } }, "services": { @@ -96,5 +101,22 @@ } } } + }, + "exceptions": { + "invalid_device": { + "message": "Device '{target}' is not a {domain} device" + }, + "device_not_found": { + "message": "Device '{target}' not found in device registry" + }, + "no_path": { + "message": "Can't write to directory {target}, no access to path!" + }, + "cant_write": { + "message": "Can't write to file" + }, + "not_loaded": { + "message": "{target} is not loaded" + } } } diff --git a/homeassistant/components/blink/switch.py b/homeassistant/components/blink/switch.py new file mode 100644 index 00000000000..197c8e08685 --- /dev/null +++ b/homeassistant/components/blink/switch.py @@ -0,0 +1,99 @@ +"""Support for Blink Motion detection switches.""" +from __future__ import annotations + +import asyncio +from typing import Any + +from homeassistant.components.switch import ( + SwitchDeviceClass, + 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 homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEFAULT_BRAND, DOMAIN, TYPE_CAMERA_ARMED +from .coordinator import BlinkUpdateCoordinator + +SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription( + key=TYPE_CAMERA_ARMED, + icon="mdi:motion-sensor", + translation_key="camera_motion", + device_class=SwitchDeviceClass.SWITCH, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Blink switches.""" + coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id] + + async_add_entities( + BlinkSwitch(coordinator, camera, description) + for camera in coordinator.api.cameras + for description in SWITCH_TYPES + ) + + +class BlinkSwitch(CoordinatorEntity[BlinkUpdateCoordinator], SwitchEntity): + """Representation of a Blink motion detection switch.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: BlinkUpdateCoordinator, + camera, + description: SwitchEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator) + self._camera = coordinator.api.cameras[camera] + self.entity_description = description + serial = self._camera.serial + self._attr_unique_id = f"{serial}-{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial)}, + serial_number=serial, + name=camera, + manufacturer=DEFAULT_BRAND, + model=self._camera.camera_type, + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + try: + await self._camera.async_arm(True) + + except asyncio.TimeoutError as er: + raise HomeAssistantError( + "Blink failed to arm camera motion detection" + ) from er + + await self.coordinator.async_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + try: + await self._camera.async_arm(False) + + except asyncio.TimeoutError as er: + raise HomeAssistantError( + "Blink failed to dis-arm camera motion detection" + ) from er + + await self.coordinator.async_refresh() + + @property + def is_on(self) -> bool: + """Return if Camera Motion is enabled.""" + return self._camera.motion_enabled diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py index 6f48080a451..ddf57aa6eee 100644 --- a/homeassistant/components/blueprint/models.py +++ b/homeassistant/components/blueprint/models.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections.abc import Awaitable, Callable import logging import pathlib import shutil @@ -189,12 +189,14 @@ class DomainBlueprints: domain: str, logger: logging.Logger, blueprint_in_use: Callable[[HomeAssistant, str], bool], + reload_blueprint_consumers: Callable[[HomeAssistant, str], Awaitable[None]], ) -> None: """Initialize a domain blueprints instance.""" self.hass = hass self.domain = domain self.logger = logger self._blueprint_in_use = blueprint_in_use + self._reload_blueprint_consumers = reload_blueprint_consumers self._blueprints: dict[str, Blueprint | None] = {} self._load_lock = asyncio.Lock() @@ -283,7 +285,7 @@ class DomainBlueprints: blueprint = await self.hass.async_add_executor_job( self._load_blueprint, blueprint_path ) - except Exception: + except FailedToLoad: self._blueprints[blueprint_path] = None raise @@ -315,31 +317,41 @@ class DomainBlueprints: await self.hass.async_add_executor_job(path.unlink) self._blueprints[blueprint_path] = None - def _create_file(self, blueprint: Blueprint, blueprint_path: str) -> None: - """Create blueprint file.""" + def _create_file( + self, blueprint: Blueprint, blueprint_path: str, allow_override: bool + ) -> bool: + """Create blueprint file. + + Returns true if the action overrides an existing blueprint. + """ path = pathlib.Path( self.hass.config.path(BLUEPRINT_FOLDER, self.domain, blueprint_path) ) - if path.exists(): + exists = path.exists() + + if not allow_override and exists: raise FileAlreadyExists(self.domain, blueprint_path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(blueprint.yaml(), encoding="utf-8") + return exists async def async_add_blueprint( - self, blueprint: Blueprint, blueprint_path: str - ) -> None: + self, blueprint: Blueprint, blueprint_path: str, allow_override=False + ) -> bool: """Add a blueprint.""" - if not blueprint_path.endswith(".yaml"): - blueprint_path = f"{blueprint_path}.yaml" - - await self.hass.async_add_executor_job( - self._create_file, blueprint, blueprint_path + overrides_existing = await self.hass.async_add_executor_job( + self._create_file, blueprint, blueprint_path, allow_override ) self._blueprints[blueprint_path] = blueprint + if overrides_existing: + await self._reload_blueprint_consumers(self.hass, blueprint_path) + + return overrides_existing + async def async_populate(self) -> None: """Create folder if it doesn't exist and populate with examples.""" if self._blueprints: diff --git a/homeassistant/components/blueprint/websocket_api.py b/homeassistant/components/blueprint/websocket_api.py index 1732320c1e9..3c7cc3769c8 100644 --- a/homeassistant/components/blueprint/websocket_api.py +++ b/homeassistant/components/blueprint/websocket_api.py @@ -14,7 +14,7 @@ from homeassistant.util import yaml from . import importer, models from .const import DOMAIN -from .errors import FileAlreadyExists +from .errors import FailedToLoad, FileAlreadyExists @callback @@ -81,6 +81,23 @@ async def ws_import_blueprint( ) return + # Check it exists and if so, which automations are using it + domain = imported_blueprint.blueprint.metadata["domain"] + domain_blueprints: models.DomainBlueprints | None = hass.data.get(DOMAIN, {}).get( + domain + ) + if domain_blueprints is None: + connection.send_error( + msg["id"], websocket_api.ERR_INVALID_FORMAT, "Unsupported domain" + ) + return + + suggested_path = f"{imported_blueprint.suggested_filename}.yaml" + try: + exists = bool(await domain_blueprints.async_get_blueprint(suggested_path)) + except FailedToLoad: + exists = False + connection.send_result( msg["id"], { @@ -90,6 +107,7 @@ async def ws_import_blueprint( "metadata": imported_blueprint.blueprint.metadata, }, "validation_errors": imported_blueprint.blueprint.validate(), + "exists": exists, }, ) @@ -101,6 +119,7 @@ async def ws_import_blueprint( vol.Required("path"): cv.path, vol.Required("yaml"): cv.string, vol.Optional("source_url"): cv.url, + vol.Optional("allow_override"): bool, } ) @websocket_api.async_response @@ -130,8 +149,13 @@ async def ws_save_blueprint( connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err)) return + if not path.endswith(".yaml"): + path = f"{path}.yaml" + try: - await domain_blueprints[domain].async_add_blueprint(blueprint, path) + overrides_existing = await domain_blueprints[domain].async_add_blueprint( + blueprint, path, allow_override=msg.get("allow_override", False) + ) except FileAlreadyExists: connection.send_error(msg["id"], "already_exists", "File already exists") return @@ -141,6 +165,9 @@ async def ws_save_blueprint( connection.send_result( msg["id"], + { + "overrides_existing": overrides_existing, + }, ) diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 8eacd3e291a..637ebbaf867 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -334,7 +334,7 @@ class BaseHaRemoteScanner(BaseHaScanner): local_name = prev_name if service_uuids and service_uuids != prev_service_uuids: - service_uuids = list(set(service_uuids + prev_service_uuids)) + service_uuids = list({*service_uuids, *prev_service_uuids}) elif not service_uuids: service_uuids = prev_service_uuids diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 813bc900900..c39c28b13f7 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak-retry-connector==3.3.0", "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", - "bluetooth-data-tools==1.14.0", - "dbus-fast==2.12.0" + "bluetooth-data-tools==1.15.0", + "dbus-fast==2.14.0" ] } diff --git a/homeassistant/components/bluetooth/update_coordinator.py b/homeassistant/components/bluetooth/update_coordinator.py index 12bff3be645..295e84d4481 100644 --- a/homeassistant/components/bluetooth/update_coordinator.py +++ b/homeassistant/components/bluetooth/update_coordinator.py @@ -47,12 +47,7 @@ class BasePassiveBluetoothCoordinator(ABC): def async_start(self) -> CALLBACK_TYPE: """Start the data updater.""" self._async_start() - - @callback - def _async_cancel() -> None: - self._async_stop() - - return _async_cancel + return self._async_stop @callback @abstractmethod diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 911a998371e..854a2f87410 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer-connected==0.14.3"] + "requirements": ["bimmer-connected[china]==0.14.6"] } diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py index 3467322a4af..1d8b736f4dd 100644 --- a/homeassistant/components/bmw_connected_drive/select.py +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -44,7 +44,8 @@ SELECT_TYPES: dict[str, BMWSelectEntityDescription] = { translation_key="ac_limit", is_available=lambda v: v.is_remote_set_ac_limit_enabled, dynamic_options=lambda v: [ - str(lim) for lim in v.charging_profile.ac_available_limits # type: ignore[union-attr] + str(lim) + for lim in v.charging_profile.ac_available_limits # type: ignore[union-attr] ], current_option=lambda v: str(v.charging_profile.ac_current_limit), # type: ignore[union-attr] remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index 46066d9f55e..b6f402004f6 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -126,10 +126,10 @@ async def async_remove_config_entry_device( for identifier in device_entry.identifiers: if identifier[0] != DOMAIN or len(identifier) != 3: continue - bond_id: str = identifier[1] + bond_id: str = identifier[1] # type: ignore[unreachable] # Bond still uses the 3 arg tuple before # the identifiers were typed - device_id: str = identifier[2] # type: ignore[misc] + device_id: str = identifier[2] # If device_id is no longer present on # the hub, we allow removal. if hub.bond_id != bond_id or not any( diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index bc6235cb219..3cb81ba40b4 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -199,10 +199,6 @@ class BondFan(BondEntity, FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if preset_mode != PRESET_MODE_BREEZE or not self._device.has_action( - Action.BREEZE_ON - ): - raise ValueError(f"Invalid preset mode: {preset_mode}") await self._hub.bond.action(self._device.device_id, Action(Action.BREEZE_ON)) async def async_turn_off(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/bond/strings.json b/homeassistant/components/bond/strings.json index 4c7c224bc44..8986905c6ee 100644 --- a/homeassistant/components/bond/strings.json +++ b/homeassistant/components/bond/strings.json @@ -12,6 +12,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "host": "The IP address of your Bond hub." } } }, diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index 90688e1373f..88eb817bbd9 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -6,6 +6,9 @@ "title": "SHC authentication parameters", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Bosch Smart Home Controller." } }, "credentials": { diff --git a/homeassistant/components/braviatv/strings.json b/homeassistant/components/braviatv/strings.json index 8f8e728cb9d..4b28fa91d74 100644 --- a/homeassistant/components/braviatv/strings.json +++ b/homeassistant/components/braviatv/strings.json @@ -5,6 +5,9 @@ "description": "Ensure that your TV is turned on before trying to set it up.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Sony Bravia TV to control." } }, "authorize": { diff --git a/homeassistant/components/broadlink/climate.py b/homeassistant/components/broadlink/climate.py new file mode 100644 index 00000000000..6937d6bb0da --- /dev/null +++ b/homeassistant/components/broadlink/climate.py @@ -0,0 +1,85 @@ +"""Support for Broadlink climate devices.""" +from typing import Any + +from homeassistant.components.climate import ( + ATTR_TEMPERATURE, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PRECISION_HALVES, Platform, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, DOMAINS_AND_TYPES +from .device import BroadlinkDevice +from .entity import BroadlinkEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Broadlink climate entities.""" + device = hass.data[DOMAIN].devices[config_entry.entry_id] + + if device.api.type in DOMAINS_AND_TYPES[Platform.CLIMATE]: + async_add_entities([BroadlinkThermostat(device)]) + + +class BroadlinkThermostat(ClimateEntity, BroadlinkEntity): + """Representation of a Broadlink Hysen climate entity.""" + + _attr_has_entity_name = True + _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF, HVACMode.AUTO] + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_target_temperature_step = PRECISION_HALVES + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + def __init__(self, device: BroadlinkDevice) -> None: + """Initialize the climate entity.""" + super().__init__(device) + self._attr_unique_id = device.unique_id + self._attr_hvac_mode = None + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = kwargs[ATTR_TEMPERATURE] + await self._device.async_request(self._device.api.set_temp, temperature) + self._attr_target_temperature = temperature + self.async_write_ha_state() + + @callback + def _update_state(self, data: dict[str, Any]) -> None: + """Update data.""" + if data.get("power"): + if data.get("auto_mode"): + self._attr_hvac_mode = HVACMode.AUTO + else: + self._attr_hvac_mode = HVACMode.HEAT + + if data.get("active"): + self._attr_hvac_action = HVACAction.HEATING + else: + self._attr_hvac_action = HVACAction.IDLE + else: + self._attr_hvac_mode = HVACMode.OFF + self._attr_hvac_action = HVACAction.OFF + + self._attr_current_temperature = data.get("room_temp") + self._attr_target_temperature = data.get("thermostat_temp") + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + if hvac_mode == HVACMode.OFF: + await self._device.async_request(self._device.api.set_power, 0) + else: + await self._device.async_request(self._device.api.set_power, 1) + mode = 0 if hvac_mode == HVACMode.HEAT else 1 + await self._device.async_request(self._device.api.set_mode, mode, 0) + + self._attr_hvac_mode = hvac_mode + self.async_write_ha_state() diff --git a/homeassistant/components/broadlink/const.py b/homeassistant/components/broadlink/const.py index c1ccc5ec954..2b9e8787a43 100644 --- a/homeassistant/components/broadlink/const.py +++ b/homeassistant/components/broadlink/const.py @@ -4,6 +4,7 @@ from homeassistant.const import Platform DOMAIN = "broadlink" DOMAINS_AND_TYPES = { + Platform.CLIMATE: {"HYS"}, Platform.REMOTE: {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"}, Platform.SENSOR: { "A1", diff --git a/homeassistant/components/broadlink/manifest.json b/homeassistant/components/broadlink/manifest.json index 5778520e530..7fd925a2ff4 100644 --- a/homeassistant/components/broadlink/manifest.json +++ b/homeassistant/components/broadlink/manifest.json @@ -1,7 +1,7 @@ { "domain": "broadlink", "name": "Broadlink", - "codeowners": ["@danielhiversen", "@felipediel", "@L-I-Am"], + "codeowners": ["@danielhiversen", "@felipediel", "@L-I-Am", "@eifinger"], "config_flow": true, "dhcp": [ { @@ -30,6 +30,9 @@ }, { "macaddress": "EC0BAE*" + }, + { + "macaddress": "780F77*" } ], "documentation": "https://www.home-assistant.io/integrations/broadlink", diff --git a/homeassistant/components/broadlink/strings.json b/homeassistant/components/broadlink/strings.json index 87567bcb7b1..335984d1ebe 100644 --- a/homeassistant/components/broadlink/strings.json +++ b/homeassistant/components/broadlink/strings.json @@ -3,10 +3,13 @@ "flow_title": "{name} ({model} at {host})", "step": { "user": { - "title": "Connect to the device", + "description": "Connect to the device", "data": { "host": "[%key:common::config_flow::data::host%]", "timeout": "Timeout" + }, + "data_description": { + "host": "The hostname or IP address of your Broadlink device." } }, "auth": { diff --git a/homeassistant/components/broadlink/updater.py b/homeassistant/components/broadlink/updater.py index da8461bf90f..10ac4df4bb8 100644 --- a/homeassistant/components/broadlink/updater.py +++ b/homeassistant/components/broadlink/updater.py @@ -16,6 +16,7 @@ def get_update_manager(device): update_managers = { "A1": BroadlinkA1UpdateManager, "BG1": BroadlinkBG1UpdateManager, + "HYS": BroadlinkThermostatUpdateManager, "LB1": BroadlinkLB1UpdateManager, "LB2": BroadlinkLB1UpdateManager, "MP1": BroadlinkMP1UpdateManager, @@ -184,3 +185,11 @@ class BroadlinkLB1UpdateManager(BroadlinkUpdateManager): async def async_fetch_data(self): """Fetch data from the device.""" return await self.device.async_request(self.device.api.get_state) + + +class BroadlinkThermostatUpdateManager(BroadlinkUpdateManager): + """Manages updates for thermostats with Broadlink DNA.""" + + async def async_fetch_data(self): + """Fetch data from the device.""" + return await self.device.async_request(self.device.api.get_full_status) diff --git a/homeassistant/components/brother/strings.json b/homeassistant/components/brother/strings.json index e24c941c514..0d8f4f4eedf 100644 --- a/homeassistant/components/brother/strings.json +++ b/homeassistant/components/brother/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "type": "Type of the printer" + }, + "data_description": { + "host": "The hostname or IP address of the Brother printer to control." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py index 224cb479dda..def2cfaf56a 100644 --- a/homeassistant/components/bsblan/__init__.py +++ b/homeassistant/components/bsblan/__init__.py @@ -1,7 +1,7 @@ """The BSB-Lan integration.""" import dataclasses -from bsblan import BSBLAN, Device, Info, State, StaticState +from bsblan import BSBLAN, Device, Info, StaticState from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -13,7 +13,6 @@ from homeassistant.const import ( ) 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_PASSKEY, DOMAIN from .coordinator import BSBLanUpdateCoordinator @@ -25,7 +24,7 @@ PLATFORMS = [Platform.CLIMATE] class HomeAssistantBSBLANData: """BSBLan data stored in the Home Assistant data object.""" - coordinator: DataUpdateCoordinator[State] + coordinator: BSBLanUpdateCoordinator client: BSBLAN device: Device info: Info diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index 39eab6e7e0a..609d5ab6e83 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -60,8 +60,7 @@ async def async_setup_entry( data.static, entry, ) - ], - True, + ] ) diff --git a/homeassistant/components/bsblan/entity.py b/homeassistant/components/bsblan/entity.py index d45749a9a86..3c7f41ce34d 100644 --- a/homeassistant/components/bsblan/entity.py +++ b/homeassistant/components/bsblan/entity.py @@ -5,7 +5,11 @@ from bsblan import BSBLAN, Device, Info, StaticState from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST -from homeassistant.helpers.device_registry import DeviceInfo, format_mac +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) from homeassistant.helpers.entity import Entity from .const import DOMAIN @@ -26,6 +30,7 @@ class BSBLANEntity(Entity): self.client = client self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, format_mac(device.MAC))}, identifiers={(DOMAIN, format_mac(device.MAC))}, manufacturer="BSBLAN Inc.", model=info.device_identification.value, diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index 0693f3fb8ea..689d1f893d3 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -11,6 +11,9 @@ "passkey": "Passkey string", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your BSB-Lan device." } } }, diff --git a/homeassistant/components/caldav/__init__.py b/homeassistant/components/caldav/__init__.py index 6fe9a8d4d19..eed06a3a005 100644 --- a/homeassistant/components/caldav/__init__.py +++ b/homeassistant/components/caldav/__init__.py @@ -1 +1,61 @@ """The caldav component.""" + +import logging + +import caldav +from caldav.lib.error import AuthorizationError, DAVError +import requests + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_PASSWORD, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.TODO] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up CalDAV from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + client = caldav.DAVClient( + entry.data[CONF_URL], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + ssl_verify_cert=entry.data[CONF_VERIFY_SSL], + ) + try: + await hass.async_add_executor_job(client.principal) + except AuthorizationError as err: + if err.reason == "Unauthorized": + raise ConfigEntryAuthFailed("Credentials error from CalDAV server") from err + # AuthorizationError can be raised if the url is incorrect or + # on some other unexpected server response. + _LOGGER.warning("Unexpected CalDAV server response: %s", err) + return False + except requests.ConnectionError as err: + raise ConfigEntryNotReady("Connection error from CalDAV server") from err + except DAVError as err: + raise ConfigEntryNotReady("CalDAV client error") from err + + hass.data[DOMAIN][entry.entry_id] = client + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/caldav/api.py b/homeassistant/components/caldav/api.py new file mode 100644 index 00000000000..fa89d6acc38 --- /dev/null +++ b/homeassistant/components/caldav/api.py @@ -0,0 +1,36 @@ +"""Library for working with CalDAV api.""" + +import asyncio + +import caldav + +from homeassistant.core import HomeAssistant + + +async def async_get_calendars( + hass: HomeAssistant, client: caldav.DAVClient, component: str +) -> list[caldav.Calendar]: + """Get all calendars that support the specified component.""" + + def _get_calendars() -> list[caldav.Calendar]: + return client.principal().calendars() + + calendars = await hass.async_add_executor_job(_get_calendars) + components_results = await asyncio.gather( + *[ + hass.async_add_executor_job(calendar.get_supported_components) + for calendar in calendars + ] + ) + return [ + calendar + for calendar, supported_components in zip(calendars, components_results) + if component in supported_components + ] + + +def get_attr_value(obj: caldav.CalendarObjectResource, attribute: str) -> str | None: + """Return the value of the CalDav object attribute if defined.""" + if hasattr(obj, attribute): + return getattr(obj, attribute).value + return None diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index 4fe5e38432a..b2114dfc829 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -1,10 +1,8 @@ """Support for WebDav Calendar.""" from __future__ import annotations -from datetime import date, datetime, time, timedelta -from functools import partial +from datetime import datetime import logging -import re import caldav import voluptuous as vol @@ -14,9 +12,9 @@ from homeassistant.components.calendar import ( PLATFORM_SCHEMA, CalendarEntity, CalendarEvent, - extract_offset, is_offset_reached, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -24,12 +22,16 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import generate_entity_id +from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle, dt as dt_util +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .api import async_get_calendars +from .const import DOMAIN +from .coordinator import CalDavUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -39,7 +41,11 @@ CONF_CALENDAR = "calendar" CONF_SEARCH = "search" CONF_DAYS = "days" -OFFSET = "!!" +# Number of days to look ahead for next event when configured by ConfigEntry +CONFIG_ENTRY_DEFAULT_DAYS = 7 + +# Only allow VCALENDARs that support this component type +SUPPORTED_COMPONENT = "VEVENT" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -64,13 +70,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( } ) -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) - -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, disc_info: DiscoveryInfoType | None = None, ) -> None: """Set up the WebDav Calendar platform.""" @@ -83,9 +87,9 @@ def setup_platform( url, None, username, password, ssl_verify_cert=config[CONF_VERIFY_SSL] ) - calendars = client.principal().calendars() + calendars = await async_get_calendars(hass, client, SUPPORTED_COMPONENT) - calendar_devices = [] + entities = [] device_id: str | None for calendar in list(calendars): # If a calendar name was given in the configuration, @@ -102,55 +106,86 @@ def setup_platform( name = cust_calendar[CONF_NAME] device_id = f"{cust_calendar[CONF_CALENDAR]} {cust_calendar[CONF_NAME]}" - entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) - calendar_devices.append( - WebDavCalendarEntity( - name=name, - calendar=calendar, - entity_id=entity_id, - days=days, - all_day=True, - search=cust_calendar[CONF_SEARCH], - ) + entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) + coordinator = CalDavUpdateCoordinator( + hass, + calendar=calendar, + days=days, + include_all_day=True, + search=cust_calendar[CONF_SEARCH], + ) + entities.append( + WebDavCalendarEntity(name, entity_id, coordinator, supports_offset=True) ) # Create a default calendar if there was no custom one for all calendars # that support events. if not config[CONF_CUSTOM_CALENDARS]: - if ( - supported_components := calendar.get_supported_components() - ) and "VEVENT" not in supported_components: - _LOGGER.debug( - "Ignoring calendar '%s' (components=%s)", - calendar.name, - supported_components, - ) - continue - name = calendar.name device_id = calendar.name - entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) - calendar_devices.append( - WebDavCalendarEntity(name, calendar, entity_id, days) + entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) + coordinator = CalDavUpdateCoordinator( + hass, + calendar=calendar, + days=days, + include_all_day=False, + search=None, + ) + entities.append( + WebDavCalendarEntity(name, entity_id, coordinator, supports_offset=True) ) - add_entities(calendar_devices, True) + async_add_entities(entities, True) -class WebDavCalendarEntity(CalendarEntity): +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the CalDav calendar platform for a config entry.""" + client: caldav.DAVClient = hass.data[DOMAIN][entry.entry_id] + calendars = await async_get_calendars(hass, client, SUPPORTED_COMPONENT) + async_add_entities( + ( + WebDavCalendarEntity( + calendar.name, + async_generate_entity_id(ENTITY_ID_FORMAT, calendar.name, hass=hass), + CalDavUpdateCoordinator( + hass, + calendar=calendar, + days=CONFIG_ENTRY_DEFAULT_DAYS, + include_all_day=True, + search=None, + ), + unique_id=f"{entry.entry_id}-{calendar.id}", + ) + for calendar in calendars + if calendar.name + ), + True, + ) + + +class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarEntity): """A device for getting the next Task from a WebDav Calendar.""" - def __init__(self, name, calendar, entity_id, days, all_day=False, search=None): + def __init__( + self, + name: str, + entity_id: str, + coordinator: CalDavUpdateCoordinator, + unique_id: str | None = None, + supports_offset: bool = False, + ) -> None: """Create the WebDav Calendar Event Device.""" - self.data = WebDavCalendarData( - calendar=calendar, - days=days, - include_all_day=all_day, - search=search, - ) + super().__init__(coordinator) self.entity_id = entity_id self._event: CalendarEvent | None = None self._attr_name = name + if unique_id is not None: + self._attr_unique_id = unique_id + self._supports_offset = supports_offset @property def event(self) -> CalendarEvent | None: @@ -161,222 +196,23 @@ class WebDavCalendarEntity(CalendarEntity): self, hass: HomeAssistant, start_date: datetime, end_date: datetime ) -> list[CalendarEvent]: """Get all events in a specific time frame.""" - return await self.data.async_get_events(hass, start_date, end_date) + return await self.coordinator.async_get_events(hass, start_date, end_date) - def update(self) -> None: + @callback + def _handle_coordinator_update(self) -> None: """Update event data.""" - self.data.update() - self._event = self.data.event - self._attr_extra_state_attributes = { - "offset_reached": is_offset_reached( - self._event.start_datetime_local, self.data.offset - ) - if self._event - else False - } - - -class WebDavCalendarData: - """Class to utilize the calendar dav client object to get next event.""" - - def __init__(self, calendar, days, include_all_day, search): - """Set up how we are going to search the WebDav calendar.""" - self.calendar = calendar - self.days = days - self.include_all_day = include_all_day - self.search = search - self.event = None - self.offset = None - - async def async_get_events( - self, hass: HomeAssistant, start_date: datetime, end_date: datetime - ) -> list[CalendarEvent]: - """Get all events in a specific time frame.""" - # Get event list from the current calendar - vevent_list = await hass.async_add_executor_job( - partial( - self.calendar.search, - start=start_date, - end=end_date, - event=True, - expand=True, - ) - ) - event_list = [] - for event in vevent_list: - if not hasattr(event.instance, "vevent"): - _LOGGER.warning("Skipped event with missing 'vevent' property") - continue - vevent = event.instance.vevent - if not self.is_matching(vevent, self.search): - continue - event_list.append( - CalendarEvent( - summary=self.get_attr_value(vevent, "summary") or "", - start=self.to_local(vevent.dtstart.value), - end=self.to_local(self.get_end_date(vevent)), - location=self.get_attr_value(vevent, "location"), - description=self.get_attr_value(vevent, "description"), + self._event = self.coordinator.data + if self._supports_offset: + self._attr_extra_state_attributes = { + "offset_reached": is_offset_reached( + self._event.start_datetime_local, self.coordinator.offset ) - ) + if self._event + else False + } + super()._handle_coordinator_update() - return event_list - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Get the latest data.""" - start_of_today = dt_util.start_of_local_day() - start_of_tomorrow = dt_util.start_of_local_day() + timedelta(days=self.days) - - # We have to retrieve the results for the whole day as the server - # won't return events that have already started - results = self.calendar.search( - start=start_of_today, - end=start_of_tomorrow, - event=True, - expand=True, - ) - - # Create new events for each recurrence of an event that happens today. - # For recurring events, some servers return the original event with recurrence rules - # and they would not be properly parsed using their original start/end dates. - new_events = [] - for event in results: - if not hasattr(event.instance, "vevent"): - _LOGGER.warning("Skipped event with missing 'vevent' property") - continue - vevent = event.instance.vevent - for start_dt in vevent.getrruleset() or []: - _start_of_today = start_of_today - _start_of_tomorrow = start_of_tomorrow - if self.is_all_day(vevent): - start_dt = start_dt.date() - _start_of_today = _start_of_today.date() - _start_of_tomorrow = _start_of_tomorrow.date() - if _start_of_today <= start_dt < _start_of_tomorrow: - new_event = event.copy() - new_vevent = new_event.instance.vevent - if hasattr(new_vevent, "dtend"): - dur = new_vevent.dtend.value - new_vevent.dtstart.value - new_vevent.dtend.value = start_dt + dur - new_vevent.dtstart.value = start_dt - new_events.append(new_event) - elif _start_of_tomorrow <= start_dt: - break - vevents = [ - event.instance.vevent - for event in results + new_events - if hasattr(event.instance, "vevent") - ] - - # dtstart can be a date or datetime depending if the event lasts a - # whole day. Convert everything to datetime to be able to sort it - vevents.sort(key=lambda x: self.to_datetime(x.dtstart.value)) - - vevent = next( - ( - vevent - for vevent in vevents - if ( - self.is_matching(vevent, self.search) - and (not self.is_all_day(vevent) or self.include_all_day) - and not self.is_over(vevent) - ) - ), - None, - ) - - # If no matching event could be found - if vevent is None: - _LOGGER.debug( - "No matching event found in the %d results for %s", - len(vevents), - self.calendar.name, - ) - self.event = None - return - - # Populate the entity attributes with the event values - (summary, offset) = extract_offset( - self.get_attr_value(vevent, "summary") or "", OFFSET - ) - self.event = CalendarEvent( - summary=summary, - start=self.to_local(vevent.dtstart.value), - end=self.to_local(self.get_end_date(vevent)), - location=self.get_attr_value(vevent, "location"), - description=self.get_attr_value(vevent, "description"), - ) - self.offset = offset - - @staticmethod - def is_matching(vevent, search): - """Return if the event matches the filter criteria.""" - if search is None: - return True - - pattern = re.compile(search) - return ( - hasattr(vevent, "summary") - and pattern.match(vevent.summary.value) - or hasattr(vevent, "location") - and pattern.match(vevent.location.value) - or hasattr(vevent, "description") - and pattern.match(vevent.description.value) - ) - - @staticmethod - def is_all_day(vevent): - """Return if the event last the whole day.""" - return not isinstance(vevent.dtstart.value, datetime) - - @staticmethod - def is_over(vevent): - """Return if the event is over.""" - return dt_util.now() >= WebDavCalendarData.to_datetime( - WebDavCalendarData.get_end_date(vevent) - ) - - @staticmethod - def to_datetime(obj): - """Return a datetime.""" - if isinstance(obj, datetime): - return WebDavCalendarData.to_local(obj) - return datetime.combine(obj, time.min).replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) - - @staticmethod - def to_local(obj: datetime | date) -> datetime | date: - """Return a datetime as a local datetime, leaving dates unchanged. - - This handles giving floating times a timezone for comparison - with all day events and dropping the custom timezone object - used by the caldav client and dateutil so the datetime can be copied. - """ - if isinstance(obj, datetime): - return dt_util.as_local(obj) - return obj - - @staticmethod - def get_attr_value(obj, attribute): - """Return the value of the attribute if defined.""" - if hasattr(obj, attribute): - return getattr(obj, attribute).value - return None - - @staticmethod - def get_end_date(obj): - """Return the end datetime as determined by dtend or duration.""" - if hasattr(obj, "dtend"): - enddate = obj.dtend.value - elif hasattr(obj, "duration"): - enddate = obj.dtstart.value + obj.duration.value - else: - enddate = obj.dtstart.value + timedelta(days=1) - - # End date for an all day event is exclusive. This fixes the case where - # an all day event has a start and end values are the same, or the event - # has a zero duration. - if not isinstance(enddate, datetime) and obj.dtstart.value == enddate: - enddate += timedelta(days=1) - - return enddate + async def async_added_to_hass(self) -> None: + """When entity is added to hass update state from existing coordinator data.""" + await super().async_added_to_hass() + self._handle_coordinator_update() diff --git a/homeassistant/components/caldav/config_flow.py b/homeassistant/components/caldav/config_flow.py new file mode 100644 index 00000000000..f2fa51c7f60 --- /dev/null +++ b/homeassistant/components/caldav/config_flow.py @@ -0,0 +1,127 @@ +"""Configuration flow for CalDav.""" + +from collections.abc import Mapping +import logging +from typing import Any + +import caldav +from caldav.lib.error import AuthorizationError, DAVError +import requests +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD, default=""): cv.string, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for caldav.""" + + VERSION = 1 + _reauth_entry: config_entries.ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match( + { + CONF_URL: user_input[CONF_URL], + CONF_USERNAME: user_input[CONF_USERNAME], + } + ) + if error := await self._test_connection(user_input): + errors["base"] = error + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + async def _test_connection(self, user_input: dict[str, Any]) -> str | None: + """Test the connection to the CalDAV server and return an error if any.""" + client = caldav.DAVClient( + user_input[CONF_URL], + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ssl_verify_cert=user_input[CONF_VERIFY_SSL], + ) + try: + await self.hass.async_add_executor_job(client.principal) + except AuthorizationError as err: + _LOGGER.warning("Authorization Error connecting to CalDAV server: %s", err) + if err.reason == "Unauthorized": + return "invalid_auth" + # AuthorizationError can be raised if the url is incorrect or + # on some other unexpected server response. + return "cannot_connect" + except requests.ConnectionError as err: + _LOGGER.warning("Connection Error connecting to CalDAV server: %s", err) + return "cannot_connect" + except DAVError as err: + _LOGGER.warning("CalDAV client error: %s", err) + return "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return "unknown" + return None + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + errors = {} + assert self._reauth_entry + if user_input is not None: + user_input = {**self._reauth_entry.data, **user_input} + + if error := await self._test_connection(user_input): + errors["base"] = error + else: + self.hass.config_entries.async_update_entry( + self._reauth_entry, data=user_input + ) + await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + description_placeholders={ + CONF_USERNAME: self._reauth_entry.data[CONF_USERNAME], + }, + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/caldav/const.py b/homeassistant/components/caldav/const.py new file mode 100644 index 00000000000..7a94a74c7a1 --- /dev/null +++ b/homeassistant/components/caldav/const.py @@ -0,0 +1,5 @@ +"""Constands for CalDAV.""" + +from typing import Final + +DOMAIN: Final = "caldav" diff --git a/homeassistant/components/caldav/coordinator.py b/homeassistant/components/caldav/coordinator.py new file mode 100644 index 00000000000..380471284de --- /dev/null +++ b/homeassistant/components/caldav/coordinator.py @@ -0,0 +1,229 @@ +"""Data update coordinator for caldav.""" + +from __future__ import annotations + +from datetime import date, datetime, time, timedelta +from functools import partial +import logging +import re + +from homeassistant.components.calendar import CalendarEvent, extract_offset +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt as dt_util + +from .api import get_attr_value + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) +OFFSET = "!!" + + +class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): + """Class to utilize the calendar dav client object to get next event.""" + + def __init__(self, hass, calendar, days, include_all_day, search): + """Set up how we are going to search the WebDav calendar.""" + super().__init__( + hass, + _LOGGER, + name=f"CalDAV {calendar.name}", + update_interval=MIN_TIME_BETWEEN_UPDATES, + ) + self.calendar = calendar + self.days = days + self.include_all_day = include_all_day + self.search = search + self.offset = None + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame.""" + # Get event list from the current calendar + vevent_list = await hass.async_add_executor_job( + partial( + self.calendar.search, + start=start_date, + end=end_date, + event=True, + expand=True, + ) + ) + event_list = [] + for event in vevent_list: + if not hasattr(event.instance, "vevent"): + _LOGGER.warning("Skipped event with missing 'vevent' property") + continue + vevent = event.instance.vevent + if not self.is_matching(vevent, self.search): + continue + event_list.append( + CalendarEvent( + summary=get_attr_value(vevent, "summary") or "", + start=self.to_local(vevent.dtstart.value), + end=self.to_local(self.get_end_date(vevent)), + location=get_attr_value(vevent, "location"), + description=get_attr_value(vevent, "description"), + ) + ) + + return event_list + + async def _async_update_data(self) -> CalendarEvent | None: + """Get the latest data.""" + start_of_today = dt_util.start_of_local_day() + start_of_tomorrow = dt_util.start_of_local_day() + timedelta(days=self.days) + + # We have to retrieve the results for the whole day as the server + # won't return events that have already started + results = await self.hass.async_add_executor_job( + partial( + self.calendar.search, + start=start_of_today, + end=start_of_tomorrow, + event=True, + expand=True, + ), + ) + + # Create new events for each recurrence of an event that happens today. + # For recurring events, some servers return the original event with recurrence rules + # and they would not be properly parsed using their original start/end dates. + new_events = [] + for event in results: + if not hasattr(event.instance, "vevent"): + _LOGGER.warning("Skipped event with missing 'vevent' property") + continue + vevent = event.instance.vevent + for start_dt in vevent.getrruleset() or []: + _start_of_today: date | datetime + _start_of_tomorrow: datetime | date + if self.is_all_day(vevent): + start_dt = start_dt.date() + _start_of_today = start_of_today.date() + _start_of_tomorrow = start_of_tomorrow.date() + else: + _start_of_today = start_of_today + _start_of_tomorrow = start_of_tomorrow + if _start_of_today <= start_dt < _start_of_tomorrow: + new_event = event.copy() + new_vevent = new_event.instance.vevent + if hasattr(new_vevent, "dtend"): + dur = new_vevent.dtend.value - new_vevent.dtstart.value + new_vevent.dtend.value = start_dt + dur + new_vevent.dtstart.value = start_dt + new_events.append(new_event) + elif _start_of_tomorrow <= start_dt: + break + vevents = [ + event.instance.vevent + for event in results + new_events + if hasattr(event.instance, "vevent") + ] + + # dtstart can be a date or datetime depending if the event lasts a + # whole day. Convert everything to datetime to be able to sort it + vevents.sort(key=lambda x: self.to_datetime(x.dtstart.value)) + + vevent = next( + ( + vevent + for vevent in vevents + if ( + self.is_matching(vevent, self.search) + and (not self.is_all_day(vevent) or self.include_all_day) + and not self.is_over(vevent) + ) + ), + None, + ) + + # If no matching event could be found + if vevent is None: + _LOGGER.debug( + "No matching event found in the %d results for %s", + len(vevents), + self.calendar.name, + ) + self.offset = None + return None + + # Populate the entity attributes with the event values + (summary, offset) = extract_offset( + get_attr_value(vevent, "summary") or "", OFFSET + ) + self.offset = offset + return CalendarEvent( + summary=summary, + start=self.to_local(vevent.dtstart.value), + end=self.to_local(self.get_end_date(vevent)), + location=get_attr_value(vevent, "location"), + description=get_attr_value(vevent, "description"), + ) + + @staticmethod + def is_matching(vevent, search): + """Return if the event matches the filter criteria.""" + if search is None: + return True + + pattern = re.compile(search) + return ( + hasattr(vevent, "summary") + and pattern.match(vevent.summary.value) + or hasattr(vevent, "location") + and pattern.match(vevent.location.value) + or hasattr(vevent, "description") + and pattern.match(vevent.description.value) + ) + + @staticmethod + def is_all_day(vevent): + """Return if the event last the whole day.""" + return not isinstance(vevent.dtstart.value, datetime) + + @staticmethod + def is_over(vevent): + """Return if the event is over.""" + return dt_util.now() >= CalDavUpdateCoordinator.to_datetime( + CalDavUpdateCoordinator.get_end_date(vevent) + ) + + @staticmethod + def to_datetime(obj): + """Return a datetime.""" + if isinstance(obj, datetime): + return CalDavUpdateCoordinator.to_local(obj) + return datetime.combine(obj, time.min).replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) + + @staticmethod + def to_local(obj: datetime | date) -> datetime | date: + """Return a datetime as a local datetime, leaving dates unchanged. + + This handles giving floating times a timezone for comparison + with all day events and dropping the custom timezone object + used by the caldav client and dateutil so the datetime can be copied. + """ + if isinstance(obj, datetime): + return dt_util.as_local(obj) + return obj + + @staticmethod + def get_end_date(obj): + """Return the end datetime as determined by dtend or duration.""" + if hasattr(obj, "dtend"): + enddate = obj.dtend.value + elif hasattr(obj, "duration"): + enddate = obj.dtstart.value + obj.duration.value + else: + enddate = obj.dtstart.value + timedelta(days=1) + + # End date for an all day event is exclusive. This fixes the case where + # an all day event has a start and end values are the same, or the event + # has a zero duration. + if not isinstance(enddate, datetime) and obj.dtstart.value == enddate: + enddate += timedelta(days=1) + + return enddate diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json index 92e2f7e67d8..a7365515758 100644 --- a/homeassistant/components/caldav/manifest.json +++ b/homeassistant/components/caldav/manifest.json @@ -2,6 +2,7 @@ "domain": "caldav", "name": "CalDAV", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/caldav", "iot_class": "cloud_polling", "loggers": ["caldav", "vobject"], diff --git a/homeassistant/components/caldav/strings.json b/homeassistant/components/caldav/strings.json new file mode 100644 index 00000000000..64fdf466b30 --- /dev/null +++ b/homeassistant/components/caldav/strings.json @@ -0,0 +1,34 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "description": "Please enter your CalDAV server credentials" + }, + "reauth_confirm": { + "description": "The password for {username} is invalid.", + "title": "[%key:common::config_flow::title::reauth%]", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + }, + "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": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + } +} diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py new file mode 100644 index 00000000000..1bd24dc542a --- /dev/null +++ b/homeassistant/components/caldav/todo.py @@ -0,0 +1,196 @@ +"""CalDAV todo platform.""" +from __future__ import annotations + +import asyncio +from datetime import date, datetime, timedelta +from functools import partial +import logging +from typing import Any, cast + +import caldav +from caldav.lib.error import DAVError, NotFoundError +import requests + +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util + +from .api import async_get_calendars, get_attr_value +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(minutes=15) + +SUPPORTED_COMPONENT = "VTODO" +TODO_STATUS_MAP = { + "NEEDS-ACTION": TodoItemStatus.NEEDS_ACTION, + "IN-PROCESS": TodoItemStatus.NEEDS_ACTION, + "COMPLETED": TodoItemStatus.COMPLETED, + "CANCELLED": TodoItemStatus.COMPLETED, +} +TODO_STATUS_MAP_INV: dict[TodoItemStatus, str] = { + TodoItemStatus.NEEDS_ACTION: "NEEDS-ACTION", + TodoItemStatus.COMPLETED: "COMPLETED", +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the CalDav todo platform for a config entry.""" + client: caldav.DAVClient = hass.data[DOMAIN][entry.entry_id] + calendars = await async_get_calendars(hass, client, SUPPORTED_COMPONENT) + async_add_entities( + ( + WebDavTodoListEntity( + calendar, + entry.entry_id, + ) + for calendar in calendars + ), + True, + ) + + +def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None: + """Convert a caldav Todo into a TodoItem.""" + if ( + not hasattr(resource.instance, "vtodo") + or not (todo := resource.instance.vtodo) + or (uid := get_attr_value(todo, "uid")) is None + or (summary := get_attr_value(todo, "summary")) is None + ): + return None + due: date | datetime | None = None + if due_value := get_attr_value(todo, "due"): + if isinstance(due_value, datetime): + due = dt_util.as_local(due_value) + elif isinstance(due_value, date): + due = due_value + return TodoItem( + uid=uid, + summary=summary, + status=TODO_STATUS_MAP.get( + get_attr_value(todo, "status") or "", + TodoItemStatus.NEEDS_ACTION, + ), + due=due, + description=get_attr_value(todo, "description"), + ) + + +def _to_ics_fields(item: TodoItem) -> dict[str, Any]: + """Convert a TodoItem to the set of add or update arguments.""" + item_data: dict[str, Any] = {} + if summary := item.summary: + item_data["summary"] = summary + if status := item.status: + item_data["status"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION") + if due := item.due: + if isinstance(due, datetime): + item_data["due"] = dt_util.as_utc(due).strftime("%Y%m%dT%H%M%SZ") + else: + item_data["due"] = due.strftime("%Y%m%d") + if description := item.description: + item_data["description"] = description + return item_data + + +class WebDavTodoListEntity(TodoListEntity): + """CalDAV To-do list entity.""" + + _attr_has_entity_name = True + _attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM + | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM + ) + + def __init__(self, calendar: caldav.Calendar, config_entry_id: str) -> None: + """Initialize WebDavTodoListEntity.""" + self._calendar = calendar + self._attr_name = (calendar.name or "Unknown").capitalize() + self._attr_unique_id = f"{config_entry_id}-{calendar.id}" + + async def async_update(self) -> None: + """Update To-do list entity state.""" + results = await self.hass.async_add_executor_job( + partial( + self._calendar.search, + todo=True, + include_completed=True, + ) + ) + self._attr_todo_items = [ + todo_item + for resource in results + if (todo_item := _todo_item(resource)) is not None + ] + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Add an item to the To-do list.""" + try: + await self.hass.async_add_executor_job( + partial(self._calendar.save_todo, **_to_ics_fields(item)), + ) + except (requests.ConnectionError, DAVError) as err: + raise HomeAssistantError(f"CalDAV save error: {err}") from err + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update a To-do item.""" + uid: str = cast(str, item.uid) + try: + todo = await self.hass.async_add_executor_job( + self._calendar.todo_by_uid, uid + ) + except NotFoundError as err: + raise HomeAssistantError(f"Could not find To-do item {uid}") from err + except (requests.ConnectionError, DAVError) as err: + raise HomeAssistantError(f"CalDAV lookup error: {err}") from err + vtodo = todo.icalendar_component # type: ignore[attr-defined] + vtodo.update(**_to_ics_fields(item)) + try: + await self.hass.async_add_executor_job( + partial( + todo.save, + no_create=True, + obj_type="todo", + ), + ) + except (requests.ConnectionError, DAVError) as err: + raise HomeAssistantError(f"CalDAV save error: {err}") from err + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Delete To-do items.""" + tasks = ( + self.hass.async_add_executor_job(self._calendar.todo_by_uid, uid) + for uid in uids + ) + + try: + items = await asyncio.gather(*tasks) + except NotFoundError as err: + raise HomeAssistantError("Could not find To-do item") from err + except (requests.ConnectionError, DAVError) as err: + raise HomeAssistantError(f"CalDAV lookup error: {err}") from err + + # Run serially as some CalDAV servers do not support concurrent modifications + for item in items: + try: + await self.hass.async_add_executor_job(item.delete) + except (requests.ConnectionError, DAVError) as err: + raise HomeAssistantError(f"CalDAV delete error: {err}") from err diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 65a61e71d3a..5b98d372220 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -37,6 +37,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_point_in_time +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util @@ -261,8 +262,10 @@ CALENDAR_EVENT_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -SERVICE_LIST_EVENTS: Final = "list_events" -SERVICE_LIST_EVENTS_SCHEMA: Final = vol.All( +LEGACY_SERVICE_LIST_EVENTS: Final = "list_events" +"""Deprecated: please use SERVICE_LIST_EVENTS.""" +SERVICE_GET_EVENTS: Final = "get_events" +SERVICE_GET_EVENTS_SCHEMA: Final = vol.All( cv.has_at_least_one_key(EVENT_END_DATETIME, EVENT_DURATION), cv.has_at_most_one_key(EVENT_END_DATETIME, EVENT_DURATION), cv.make_entity_service_schema( @@ -300,12 +303,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_create_event, required_features=[CalendarEntityFeature.CREATE_EVENT], ) - component.async_register_entity_service( - SERVICE_LIST_EVENTS, - SERVICE_LIST_EVENTS_SCHEMA, + component.async_register_legacy_entity_service( + LEGACY_SERVICE_LIST_EVENTS, + SERVICE_GET_EVENTS_SCHEMA, async_list_events_service, supports_response=SupportsResponse.ONLY, ) + component.async_register_entity_service( + SERVICE_GET_EVENTS, + SERVICE_GET_EVENTS_SCHEMA, + async_get_events_service, + supports_response=SupportsResponse.ONLY, + ) await component.async_setup(config) return True @@ -850,6 +859,32 @@ async def async_create_event(entity: CalendarEntity, call: ServiceCall) -> None: async def async_list_events_service( calendar: CalendarEntity, service_call: ServiceCall +) -> ServiceResponse: + """List events on a calendar during a time range. + + Deprecated: please use async_get_events_service. + """ + _LOGGER.warning( + "Detected use of service 'calendar.list_events'. " + "This is deprecated and will stop working in Home Assistant 2024.6. " + "Use 'calendar.get_events' instead which supports multiple entities", + ) + async_create_issue( + calendar.hass, + DOMAIN, + "deprecated_service_calendar_list_events", + breaks_in_ha_version="2024.6.0", + is_fixable=True, + is_persistent=False, + issue_domain=calendar.platform.platform_name, + severity=IssueSeverity.WARNING, + translation_key="deprecated_service_calendar_list_events", + ) + return await async_get_events_service(calendar, service_call) + + +async def async_get_events_service( + calendar: CalendarEntity, service_call: ServiceCall ) -> ServiceResponse: """List events on a calendar during a time range.""" start = service_call.data.get(EVENT_START_DATETIME, dt_util.now()) diff --git a/homeassistant/components/calendar/services.yaml b/homeassistant/components/calendar/services.yaml index 712d6ad8823..2e926fbdeed 100644 --- a/homeassistant/components/calendar/services.yaml +++ b/homeassistant/components/calendar/services.yaml @@ -52,3 +52,19 @@ list_events: duration: selector: duration: +get_events: + target: + entity: + domain: calendar + fields: + start_date_time: + example: "2022-03-22 20:00:00" + selector: + datetime: + end_date_time: + example: "2022-03-22 22:00:00" + selector: + datetime: + duration: + selector: + duration: diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 20679ed09b2..78b8407240c 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -72,9 +72,9 @@ } } }, - "list_events": { - "name": "List event", - "description": "Lists events on a calendar within a time range.", + "get_events": { + "name": "Get events", + "description": "Get events on a calendar within a time range.", "fields": { "start_date_time": { "name": "Start time", @@ -89,6 +89,37 @@ "description": "Returns active events from start_date_time until the specified duration." } } + }, + "list_events": { + "name": "List event", + "description": "Lists events on a calendar within a time range.", + "fields": { + "start_date_time": { + "name": "[%key:component::calendar::services::get_events::fields::start_date_time::name%]", + "description": "[%key:component::calendar::services::get_events::fields::start_date_time::description%]" + }, + "end_date_time": { + "name": "[%key:component::calendar::services::get_events::fields::end_date_time::name%]", + "description": "[%key:component::calendar::services::get_events::fields::end_date_time::description%]" + }, + "duration": { + "name": "[%key:component::calendar::services::get_events::fields::duration::name%]", + "description": "[%key:component::calendar::services::get_events::fields::duration::description%]" + } + } + } + }, + "issues": { + "deprecated_service_calendar_list_events": { + "title": "Detected use of deprecated service `calendar.list_events`", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::calendar::issues::deprecated_service_calendar_list_events::title%]", + "description": "Use `calendar.get_events` instead which supports multiple entities.\n\nPlease replace this service and adjust your automations and scripts and select **submit** to close this issue." + } + } + } } } } diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py new file mode 100644 index 00000000000..23cc3d5bcd2 --- /dev/null +++ b/homeassistant/components/climate/intent.py @@ -0,0 +1,68 @@ +"""Intents for the client integration.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import intent +from homeassistant.helpers.entity_component import EntityComponent + +from . import DOMAIN, ClimateEntity + +INTENT_GET_TEMPERATURE = "HassClimateGetTemperature" + + +async def async_setup_intents(hass: HomeAssistant) -> None: + """Set up the climate intents.""" + intent.async_register(hass, GetTemperatureIntent()) + + +class GetTemperatureIntent(intent.IntentHandler): + """Handle GetTemperature intents.""" + + intent_type = INTENT_GET_TEMPERATURE + slot_schema = {vol.Optional("area"): str} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + + component: EntityComponent[ClimateEntity] = hass.data[DOMAIN] + entities: list[ClimateEntity] = list(component.entities) + climate_entity: ClimateEntity | None = None + climate_state: State | None = None + + if not entities: + raise intent.IntentHandleError("No climate entities") + + if "area" in slots: + # Filter by area + area_name = slots["area"]["value"] + + for maybe_climate in intent.async_match_states( + hass, area_name=area_name, domains=[DOMAIN] + ): + climate_state = maybe_climate + break + + if climate_state is None: + raise intent.IntentHandleError(f"No climate entity in area {area_name}") + + climate_entity = component.get_entity(climate_state.entity_id) + else: + # First entity + climate_entity = entities[0] + climate_state = hass.states.get(climate_entity.entity_id) + + assert climate_entity is not None + + if climate_state is None: + raise intent.IntentHandleError(f"No state for {climate_entity.name}") + + assert climate_state is not None + + response = intent_obj.create_response() + response.response_type = intent.IntentResponseType.QUERY_ANSWER + response.async_set_states(matched_states=[climate_state]) + return response diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 41ea4aa2b7d..019936869a1 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -230,6 +230,7 @@ class CloudClient(Interface): "alias": self.cloud.remote.alias, }, "version": HA_VERSION, + "instance_id": self.prefs.instance_id, } async def async_alexa_message(self, payload: dict[Any, Any]) -> dict[Any, Any]: diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index bd9d61cde16..6e20978ec8d 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -13,6 +13,7 @@ PREF_GOOGLE_REPORT_STATE = "google_report_state" PREF_ALEXA_ENTITY_CONFIGS = "alexa_entity_configs" PREF_ALEXA_REPORT_STATE = "alexa_report_state" PREF_DISABLE_2FA = "disable_2fa" +PREF_INSTANCE_ID = "instance_id" PREF_SHOULD_EXPOSE = "should_expose" PREF_GOOGLE_LOCAL_WEBHOOK_ID = "google_local_webhook_id" PREF_USERNAME = "username" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index e3b1b39f687..634a5e20b33 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -140,7 +140,7 @@ def _ws_handle_cloud_errors( handler: Callable[ [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]], Coroutine[None, None, None], - ] + ], ) -> Callable[ [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]], Coroutine[None, None, None], @@ -362,8 +362,11 @@ def _require_cloud_login( handler: Callable[ [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]], None, - ] -) -> Callable[[HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]], None,]: + ], +) -> Callable[ + [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]], + None, +]: """Websocket decorator that requires cloud to be logged in.""" @wraps(handler) diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 57179431574..4cc02867347 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from typing import Any +import uuid from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.models import User @@ -33,6 +34,7 @@ from .const import ( PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_GOOGLE_SETTINGS_VERSION, + PREF_INSTANCE_ID, PREF_REMOTE_DOMAIN, PREF_TTS_DEFAULT_VOICE, PREF_USERNAME, @@ -91,6 +93,13 @@ class CloudPreferences: PREF_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), } ) + if PREF_INSTANCE_ID not in self._prefs: + await self._save_prefs( + { + **self._prefs, + PREF_INSTANCE_ID: uuid.uuid4().hex, + } + ) @callback def async_listen_updates( @@ -264,6 +273,11 @@ class CloudPreferences: """Return the published cloud webhooks.""" return self._prefs.get(PREF_CLOUDHOOKS, {}) # type: ignore[no-any-return] + @property + def instance_id(self) -> str | None: + """Return the instance ID.""" + return self._prefs.get(PREF_INSTANCE_ID) + @property def tts_default_voice(self) -> tuple[str, str]: """Return the default TTS voice.""" @@ -320,6 +334,7 @@ class CloudPreferences: PREF_GOOGLE_ENTITY_CONFIGS: {}, PREF_GOOGLE_SETTINGS_VERSION: GOOGLE_SETTINGS_VERSION, PREF_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), + PREF_INSTANCE_ID: uuid.uuid4().hex, PREF_GOOGLE_SECURE_DEVICES_PIN: None, PREF_REMOTE_DOMAIN: None, PREF_USERNAME: username, diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index aba2e770bc9..9c1f29cfcaf 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -13,6 +13,7 @@ "alexa_enabled": "Alexa Enabled", "google_enabled": "Google Enabled", "logged_in": "Logged In", + "instance_id": "Instance ID", "subscription_expiration": "Subscription Expiration" } }, diff --git a/homeassistant/components/cloud/system_health.py b/homeassistant/components/cloud/system_health.py index 0dfd69344f3..d149e13c996 100644 --- a/homeassistant/components/cloud/system_health.py +++ b/homeassistant/components/cloud/system_health.py @@ -37,6 +37,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: data["google_enabled"] = client.prefs.google_enabled data["remote_server"] = cloud.remote.snitun_server data["certificate_status"] = cloud.remote.certificate_status + data["instance_id"] = client.prefs.instance_id data["can_reach_cert_server"] = system_health.async_check_can_reach_url( hass, f"https://{cloud.acme_server}/directory" diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index 88f24d1290f..f8152243bf5 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -150,4 +150,4 @@ class CloudProvider(Provider): _LOGGER.error("Voice error: %s", err) return (None, None) - return (str(options[ATTR_AUDIO_OUTPUT]), data) + return (str(options[ATTR_AUDIO_OUTPUT].value), data) diff --git a/homeassistant/components/cloudflare/__init__.py b/homeassistant/components/cloudflare/__init__.py index 9608347c8e7..d4c6775c6b9 100644 --- a/homeassistant/components/cloudflare/__init__.py +++ b/homeassistant/components/cloudflare/__init__.py @@ -1,17 +1,12 @@ """Update the IP addresses of your Cloudflare DNS records.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging +import socket -from aiohttp import ClientSession -from pycfdns import CloudflareUpdater -from pycfdns.exceptions import ( - CloudflareAuthenticationException, - CloudflareConnectionException, - CloudflareException, - CloudflareZoneException, -) +import pycfdns from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_TOKEN, CONF_ZONE @@ -37,32 +32,43 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Cloudflare from a config entry.""" session = async_get_clientsession(hass) - cfupdate = CloudflareUpdater( - session, - entry.data[CONF_API_TOKEN], - entry.data[CONF_ZONE], - entry.data[CONF_RECORDS], + client = pycfdns.Client( + api_token=entry.data[CONF_API_TOKEN], + client_session=session, ) try: - zone_id = await cfupdate.get_zone_id() - except CloudflareAuthenticationException as error: + dns_zones = await client.list_zones() + dns_zone = next( + zone for zone in dns_zones if zone["name"] == entry.data[CONF_ZONE] + ) + except pycfdns.AuthenticationException as error: raise ConfigEntryAuthFailed from error - except (CloudflareConnectionException, CloudflareZoneException) as error: + except pycfdns.ComunicationException as error: raise ConfigEntryNotReady from error async def update_records(now): """Set up recurring update.""" try: - await _async_update_cloudflare(session, cfupdate, zone_id) - except CloudflareException as error: + await _async_update_cloudflare( + hass, client, dns_zone, entry.data[CONF_RECORDS] + ) + except ( + pycfdns.AuthenticationException, + pycfdns.ComunicationException, + ) as error: _LOGGER.error("Error updating zone %s: %s", entry.data[CONF_ZONE], error) async def update_records_service(call: ServiceCall) -> None: """Set up service for manual trigger.""" try: - await _async_update_cloudflare(session, cfupdate, zone_id) - except CloudflareException as error: + await _async_update_cloudflare( + hass, client, dns_zone, entry.data[CONF_RECORDS] + ) + except ( + pycfdns.AuthenticationException, + pycfdns.ComunicationException, + ) as error: _LOGGER.error("Error updating zone %s: %s", entry.data[CONF_ZONE], error) update_interval = timedelta(minutes=DEFAULT_UPDATE_INTERVAL) @@ -86,19 +92,44 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_update_cloudflare( - session: ClientSession, - cfupdate: CloudflareUpdater, - zone_id: str, + hass: HomeAssistant, + client: pycfdns.Client, + dns_zone: pycfdns.ZoneModel, + target_records: list[str], ) -> None: - _LOGGER.debug("Starting update for zone %s", cfupdate.zone) + _LOGGER.debug("Starting update for zone %s", dns_zone["name"]) - records = await cfupdate.get_record_info(zone_id) + records = await client.list_dns_records(zone_id=dns_zone["id"], type="A") _LOGGER.debug("Records: %s", records) + session = async_get_clientsession(hass, family=socket.AF_INET) location_info = await async_detect_location_info(session) if not location_info or not is_ipv4_address(location_info.ip): raise HomeAssistantError("Could not get external IPv4 address") - await cfupdate.update_records(zone_id, records, location_info.ip) - _LOGGER.debug("Update for zone %s is complete", cfupdate.zone) + filtered_records = [ + record + for record in records + if record["name"] in target_records and record["content"] != location_info.ip + ] + + if len(filtered_records) == 0: + _LOGGER.debug("All target records are up to date") + return + + await asyncio.gather( + *[ + client.update_dns_record( + zone_id=dns_zone["id"], + record_id=record["id"], + record_content=location_info.ip, + record_name=record["name"], + record_type=record["type"], + record_proxied=record["proxied"], + ) + for record in filtered_records + ] + ) + + _LOGGER.debug("Update for zone %s is complete", dns_zone["name"]) diff --git a/homeassistant/components/cloudflare/config_flow.py b/homeassistant/components/cloudflare/config_flow.py index 215411bc667..99f6109be4a 100644 --- a/homeassistant/components/cloudflare/config_flow.py +++ b/homeassistant/components/cloudflare/config_flow.py @@ -5,12 +5,7 @@ from collections.abc import Mapping import logging from typing import Any -from pycfdns import CloudflareUpdater -from pycfdns.exceptions import ( - CloudflareAuthenticationException, - CloudflareConnectionException, - CloudflareZoneException, -) +import pycfdns import voluptuous as vol from homeassistant.components import persistent_notification @@ -23,6 +18,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_RECORDS, DOMAIN +from .helpers import get_zone_id _LOGGER = logging.getLogger(__name__) @@ -33,54 +29,45 @@ DATA_SCHEMA = vol.Schema( ) -def _zone_schema(zones: list[str] | None = None) -> vol.Schema: +def _zone_schema(zones: list[pycfdns.ZoneModel] | None = None) -> vol.Schema: """Zone selection schema.""" zones_list = [] if zones is not None: - zones_list = zones + zones_list = [zones["name"] for zones in zones] return vol.Schema({vol.Required(CONF_ZONE): vol.In(zones_list)}) -def _records_schema(records: list[str] | None = None) -> vol.Schema: +def _records_schema(records: list[pycfdns.RecordModel] | None = None) -> vol.Schema: """Zone records selection schema.""" records_dict = {} if records: - records_dict = {name: name for name in records} + records_dict = {name["name"]: name["name"] for name in records} return vol.Schema({vol.Required(CONF_RECORDS): cv.multi_select(records_dict)}) async def _validate_input( - hass: HomeAssistant, data: dict[str, Any] -) -> dict[str, list[str] | None]: + hass: HomeAssistant, + data: dict[str, Any], +) -> dict[str, Any]: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. """ zone = data.get(CONF_ZONE) - records: list[str] | None = None + records: list[pycfdns.RecordModel] = [] - cfupdate = CloudflareUpdater( - async_get_clientsession(hass), - data[CONF_API_TOKEN], - zone, - [], + client = pycfdns.Client( + api_token=data[CONF_API_TOKEN], + client_session=async_get_clientsession(hass), ) - try: - zones: list[str] | None = await cfupdate.get_zones() - if zone: - zone_id = await cfupdate.get_zone_id() - records = await cfupdate.get_zone_records(zone_id, "A") - except CloudflareConnectionException as error: - raise CannotConnect from error - except CloudflareAuthenticationException as error: - raise InvalidAuth from error - except CloudflareZoneException as error: - raise InvalidZone from error + zones = await client.list_zones() + if zone and (zone_id := get_zone_id(zone, zones)) is not None: + records = await client.list_dns_records(zone_id=zone_id, type="A") return {"zones": zones, "records": records} @@ -95,8 +82,8 @@ class CloudflareConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the Cloudflare config flow.""" self.cloudflare_config: dict[str, Any] = {} - self.zones: list[str] | None = None - self.records: list[str] | None = None + self.zones: list[pycfdns.ZoneModel] | None = None + self.records: list[pycfdns.RecordModel] | None = None async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with Cloudflare.""" @@ -195,18 +182,16 @@ class CloudflareConfigFlow(ConfigFlow, domain=DOMAIN): async def _async_validate_or_error( self, config: dict[str, Any] - ) -> tuple[dict[str, list[str] | None], dict[str, str]]: + ) -> tuple[dict[str, list[Any]], dict[str, str]]: errors: dict[str, str] = {} info = {} try: info = await _validate_input(self.hass, config) - except CannotConnect: + except pycfdns.ComunicationException: errors["base"] = "cannot_connect" - except InvalidAuth: + except pycfdns.AuthenticationException: errors["base"] = "invalid_auth" - except InvalidZone: - errors["base"] = "invalid_zone" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" @@ -220,7 +205,3 @@ class CannotConnect(HomeAssistantError): class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" - - -class InvalidZone(HomeAssistantError): - """Error to indicate we cannot validate zone exists in account.""" diff --git a/homeassistant/components/cloudflare/helpers.py b/homeassistant/components/cloudflare/helpers.py new file mode 100644 index 00000000000..0542bce0980 --- /dev/null +++ b/homeassistant/components/cloudflare/helpers.py @@ -0,0 +1,10 @@ +"""Helpers for the CloudFlare integration.""" +import pycfdns + + +def get_zone_id(target_zone_name: str, zones: list[pycfdns.ZoneModel]) -> str | None: + """Get the zone ID for the target zone name.""" + for zone in zones: + if zone["name"] == target_zone_name: + return zone["id"] + return None diff --git a/homeassistant/components/cloudflare/manifest.json b/homeassistant/components/cloudflare/manifest.json index 8c901de3984..0f689aa3e03 100644 --- a/homeassistant/components/cloudflare/manifest.json +++ b/homeassistant/components/cloudflare/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/cloudflare", "iot_class": "cloud_push", "loggers": ["pycfdns"], - "requirements": ["pycfdns==2.0.1"] + "requirements": ["pycfdns==3.0.0"] } diff --git a/homeassistant/components/cloudflare/strings.json b/homeassistant/components/cloudflare/strings.json index 080be414b5c..75dc8f079c7 100644 --- a/homeassistant/components/cloudflare/strings.json +++ b/homeassistant/components/cloudflare/strings.json @@ -30,8 +30,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_zone": "Invalid zone" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", diff --git a/homeassistant/components/co2signal/__init__.py b/homeassistant/components/co2signal/__init__.py index 04ae811197b..028d37a73c5 100644 --- a/homeassistant/components/co2signal/__init__.py +++ b/homeassistant/components/co2signal/__init__.py @@ -1,9 +1,12 @@ """The CO2 Signal integration.""" from __future__ import annotations +from aioelectricitymaps import ElectricityMaps + from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN from .coordinator import CO2SignalCoordinator @@ -13,7 +16,10 @@ PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up CO2 Signal from a config entry.""" - coordinator = CO2SignalCoordinator(hass, entry) + session = async_get_clientsession(hass) + coordinator = CO2SignalCoordinator( + hass, ElectricityMaps(token=entry.data[CONF_API_KEY], session=session) + ) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index d41bd6e0f78..234c1c01392 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -1,13 +1,18 @@ """Config flow for Co2signal integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any +from aioelectricitymaps import ElectricityMaps +from aioelectricitymaps.exceptions import ElectricityMapsError, InvalidToken import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( SelectSelector, @@ -16,8 +21,7 @@ from homeassistant.helpers.selector import ( ) from .const import CONF_COUNTRY_CODE, DOMAIN -from .coordinator import get_data -from .exceptions import APIRatelimitExceeded, InvalidAuth +from .helpers import fetch_latest_carbon_intensity from .util import get_extra_name TYPE_USE_HOME = "use_home_location" @@ -30,6 +34,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 _data: dict | None + _reauth_entry: ConfigEntry | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -111,25 +116,52 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): "country", data_schema, {**self._data, **user_input} ) + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle the reauth step.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + + data_schema = vol.Schema( + { + vol.Required(CONF_API_KEY): cv.string, + } + ) + return await self._validate_and_create("reauth", data_schema, entry_data) + async def _validate_and_create( - self, step_id: str, data_schema: vol.Schema, data: dict + self, step_id: str, data_schema: vol.Schema, data: Mapping[str, Any] ) -> FlowResult: """Validate data and show form if it is invalid.""" errors: dict[str, str] = {} - try: - await self.hass.async_add_executor_job(get_data, self.hass, data) - except InvalidAuth: - errors["base"] = "invalid_auth" - except APIRatelimitExceeded: - errors["base"] = "api_ratelimit" - except Exception: # pylint: disable=broad-except - errors["base"] = "unknown" - else: - return self.async_create_entry( - title=get_extra_name(data) or "CO2 Signal", - data=data, - ) + if data: + session = async_get_clientsession(self.hass) + em = ElectricityMaps(token=data[CONF_API_KEY], session=session) + + try: + await fetch_latest_carbon_intensity(self.hass, em, data) + except InvalidToken: + errors["base"] = "invalid_auth" + except ElectricityMapsError: + errors["base"] = "unknown" + else: + if self._reauth_entry: + self.hass.config_entries.async_update_entry( + self._reauth_entry, + data={ + CONF_API_KEY: data[CONF_API_KEY], + }, + ) + await self.hass.config_entries.async_reload( + self._reauth_entry.entry_id + ) + return self.async_abort(reason="reauth_successful") + + return self.async_create_entry( + title=get_extra_name(data) or "CO2 Signal", + data=data, + ) return self.async_show_form( step_id=step_id, diff --git a/homeassistant/components/co2signal/coordinator.py b/homeassistant/components/co2signal/coordinator.py index 24d7bbd18af..115c976b465 100644 --- a/homeassistant/components/co2signal/coordinator.py +++ b/homeassistant/components/co2signal/coordinator.py @@ -1,94 +1,49 @@ """DataUpdateCoordinator for the co2signal integration.""" from __future__ import annotations -from collections.abc import Mapping from datetime import timedelta import logging -from typing import Any, cast -import CO2Signal -from requests.exceptions import JSONDecodeError +from aioelectricitymaps import ElectricityMaps +from aioelectricitymaps.exceptions import ElectricityMapsError, InvalidToken +from aioelectricitymaps.models import CarbonIntensityResponse from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_COUNTRY_CODE, DOMAIN -from .exceptions import APIRatelimitExceeded, CO2Error, InvalidAuth, UnknownError -from .models import CO2SignalResponse +from .const import DOMAIN +from .helpers import fetch_latest_carbon_intensity _LOGGER = logging.getLogger(__name__) -class CO2SignalCoordinator(DataUpdateCoordinator[CO2SignalResponse]): +class CO2SignalCoordinator(DataUpdateCoordinator[CarbonIntensityResponse]): """Data update coordinator.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, client: ElectricityMaps) -> None: """Initialize the coordinator.""" super().__init__( hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=15) ) - self._entry = entry + self.client = client @property def entry_id(self) -> str: """Return entry ID.""" - return self._entry.entry_id + return self.config_entry.entry_id - async def _async_update_data(self) -> CO2SignalResponse: + async def _async_update_data(self) -> CarbonIntensityResponse: """Fetch the latest data from the source.""" + try: - data = await self.hass.async_add_executor_job( - get_data, self.hass, self._entry.data + return await fetch_latest_carbon_intensity( + self.hass, self.client, self.config_entry.data ) - except InvalidAuth as err: + except InvalidToken as err: raise ConfigEntryAuthFailed from err - except CO2Error as err: + except ElectricityMapsError as err: raise UpdateFailed(str(err)) from err - - return data - - -def get_data(hass: HomeAssistant, config: Mapping[str, Any]) -> CO2SignalResponse: - """Get data from the API.""" - if CONF_COUNTRY_CODE in config: - latitude = None - longitude = None - else: - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - - try: - data = CO2Signal.get_latest( - config[CONF_API_KEY], - config.get(CONF_COUNTRY_CODE), - latitude, - longitude, - wait=False, - ) - - except JSONDecodeError as err: - # raise occasional occurring json decoding errors as CO2Error so the data update coordinator retries it - raise CO2Error from err - - except ValueError as err: - err_str = str(err) - - if "Invalid authentication credentials" in err_str: - raise InvalidAuth from err - if "API rate limit exceeded." in err_str: - raise APIRatelimitExceeded from err - - _LOGGER.exception("Unexpected exception") - raise UnknownError from err - - if "error" in data: - raise UnknownError(data["error"]) - - if data.get("status") != "ok": - _LOGGER.exception("Unexpected response: %s", data) - raise UnknownError - - return cast(CO2SignalResponse, data) diff --git a/homeassistant/components/co2signal/diagnostics.py b/homeassistant/components/co2signal/diagnostics.py index db08aa4eca6..1c53f7c5b08 100644 --- a/homeassistant/components/co2signal/diagnostics.py +++ b/homeassistant/components/co2signal/diagnostics.py @@ -1,6 +1,7 @@ """Diagnostics support for CO2Signal.""" from __future__ import annotations +from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data @@ -22,5 +23,5 @@ async def async_get_config_entry_diagnostics( return { "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), - "data": coordinator.data, + "data": asdict(coordinator.data), } diff --git a/homeassistant/components/co2signal/exceptions.py b/homeassistant/components/co2signal/exceptions.py deleted file mode 100644 index cc8ee709bde..00000000000 --- a/homeassistant/components/co2signal/exceptions.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Exceptions to the co2signal integration.""" -from homeassistant.exceptions import HomeAssistantError - - -class CO2Error(HomeAssistantError): - """Base error.""" - - -class InvalidAuth(CO2Error): - """Raised when invalid authentication credentials are provided.""" - - -class APIRatelimitExceeded(CO2Error): - """Raised when the API rate limit is exceeded.""" - - -class UnknownError(CO2Error): - """Raised when an unknown error occurs.""" diff --git a/homeassistant/components/co2signal/helpers.py b/homeassistant/components/co2signal/helpers.py new file mode 100644 index 00000000000..43579c162e2 --- /dev/null +++ b/homeassistant/components/co2signal/helpers.py @@ -0,0 +1,28 @@ +"""Helper functions for the CO2 Signal integration.""" +from collections.abc import Mapping +from typing import Any + +from aioelectricitymaps import ElectricityMaps +from aioelectricitymaps.models import CarbonIntensityResponse + +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant + +from .const import CONF_COUNTRY_CODE + + +async def fetch_latest_carbon_intensity( + hass: HomeAssistant, + em: ElectricityMaps, + config: Mapping[str, Any], +) -> CarbonIntensityResponse: + """Fetch the latest carbon intensity based on country code or location coordinates.""" + if CONF_COUNTRY_CODE in config: + return await em.latest_carbon_intensity_by_country_code( + code=config[CONF_COUNTRY_CODE] + ) + + return await em.latest_carbon_intensity_by_coordinates( + lat=config.get(CONF_LATITUDE, hass.config.latitude), + lon=config.get(CONF_LONGITUDE, hass.config.longitude), + ) diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index a4d7c55d6da..d82af5b5034 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/co2signal", "integration_type": "service", "iot_class": "cloud_polling", - "loggers": ["CO2Signal"], - "requirements": ["CO2Signal==0.4.2"] + "loggers": ["aioelectricitymaps"], + "requirements": ["aioelectricitymaps==0.1.5"] } diff --git a/homeassistant/components/co2signal/models.py b/homeassistant/components/co2signal/models.py deleted file mode 100644 index 758bb15c5f0..00000000000 --- a/homeassistant/components/co2signal/models.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Models to the co2signal integration.""" -from typing import TypedDict - - -class CO2SignalData(TypedDict): - """Data field.""" - - carbonIntensity: float - fossilFuelPercentage: float - - -class CO2SignalUnit(TypedDict): - """Unit field.""" - - carbonIntensity: str - - -class CO2SignalResponse(TypedDict): - """API response.""" - - status: str - countryCode: str - data: CO2SignalData - units: CO2SignalUnit diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index d00bdf70d3e..00051d8bec9 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -1,9 +1,10 @@ """Support for the CO2signal platform.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from datetime import timedelta -from typing import cast + +from aioelectricitymaps.models import CarbonIntensityResponse from homeassistant.components.sensor import ( SensorEntity, @@ -20,15 +21,17 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION, DOMAIN from .coordinator import CO2SignalCoordinator -SCAN_INTERVAL = timedelta(minutes=3) - -@dataclass +@dataclass(kw_only=True) class CO2SensorEntityDescription(SensorEntityDescription): """Provide a description of a CO2 sensor.""" # For backwards compat, allow description to override unique ID key to use unique_id: str | None = None + unit_of_measurement_fn: Callable[ + [CarbonIntensityResponse], str | None + ] | None = None + value_fn: Callable[[CarbonIntensityResponse], float | None] SENSORS = ( @@ -36,12 +39,14 @@ SENSORS = ( key="carbonIntensity", translation_key="carbon_intensity", unique_id="co2intensity", - # No unit, it's extracted from response. + value_fn=lambda response: response.data.carbon_intensity, + unit_of_measurement_fn=lambda response: response.units.carbon_intensity, ), CO2SensorEntityDescription( key="fossilFuelPercentage", translation_key="fossil_fuel_percentage", native_unit_of_measurement=PERCENTAGE, + value_fn=lambda response: response.data.fossil_fuel_percentage, ), ) @@ -51,7 +56,9 @@ async def async_setup_entry( ) -> None: """Set up the CO2signal sensor.""" coordinator: CO2SignalCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities(CO2Sensor(coordinator, description) for description in SENSORS) + async_add_entities( + [CO2Sensor(coordinator, description) for description in SENSORS], False + ) class CO2Sensor(CoordinatorEntity[CO2SignalCoordinator], SensorEntity): @@ -71,7 +78,7 @@ class CO2Sensor(CoordinatorEntity[CO2SignalCoordinator], SensorEntity): self.entity_description = description self._attr_extra_state_attributes = { - "country_code": coordinator.data["countryCode"], + "country_code": coordinator.data.country_code, } self._attr_device_info = DeviceInfo( configuration_url="https://www.electricitymaps.com/", @@ -84,26 +91,15 @@ class CO2Sensor(CoordinatorEntity[CO2SignalCoordinator], SensorEntity): f"{coordinator.entry_id}_{description.unique_id or description.key}" ) - @property - def available(self) -> bool: - """Return True if entity is available.""" - return ( - super().available - and self.entity_description.key in self.coordinator.data["data"] - ) - @property def native_value(self) -> float | None: """Return sensor state.""" - if (value := self.coordinator.data["data"][self.entity_description.key]) is None: # type: ignore[literal-required] - return None - return round(value, 2) + return self.entity_description.value_fn(self.coordinator.data) @property def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" - if self.entity_description.native_unit_of_measurement: - return self.entity_description.native_unit_of_measurement - return cast( - str, self.coordinator.data["units"].get(self.entity_description.key) - ) + if self.entity_description.unit_of_measurement_fn: + return self.entity_description.unit_of_measurement_fn(self.coordinator.data) + + return self.entity_description.native_unit_of_measurement diff --git a/homeassistant/components/co2signal/strings.json b/homeassistant/components/co2signal/strings.json index 4564fdf14be..89289dd816d 100644 --- a/homeassistant/components/co2signal/strings.json +++ b/homeassistant/components/co2signal/strings.json @@ -18,6 +18,11 @@ "data": { "country_code": "Country code" } + }, + "reauth": { + "data": { + "api_key": "[%key:common::config_flow::data::access_token%]" + } } }, "error": { @@ -28,7 +33,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "api_ratelimit": "[%key:component::co2signal::config::error::api_ratelimit%]" + "api_ratelimit": "[%key:component::co2signal::config::error::api_ratelimit%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index d3bc973429b..1573d5cb627 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -68,13 +68,13 @@ class ComelitSerialBridge(DataUpdateCoordinator): async def _async_update_data(self) -> dict[str, Any]: """Update device data.""" _LOGGER.debug("Polling Comelit Serial Bridge host: %s", self._host) + try: await self.api.login() + return await self.api.get_all_devices() except exceptions.CannotConnect as err: _LOGGER.warning("Connection error for %s", self._host) await self.api.close() raise UpdateFailed(f"Error fetching data: {repr(err)}") from err except exceptions.CannotAuthenticate: raise ConfigEntryAuthFailed - - return await self.api.get_all_devices() diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 77796ac7e7f..89157b54255 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/comelit", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.5.2"] + "requirements": ["aiocomelit==0.6.2"] } diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index 730674e913a..73c2c7d00c6 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -13,6 +13,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "host": "The hostname or IP address of your Comelit device." } } }, diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index 3ccd0bd1503..f559812207f 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -3,13 +3,9 @@ from __future__ import annotations import asyncio from datetime import timedelta - -import voluptuous as vol +from typing import cast from homeassistant.components.binary_sensor import ( - DEVICE_CLASSES_SCHEMA, - DOMAIN as BINARY_SENSOR_DOMAIN, - PLATFORM_SCHEMA, BinarySensorDeviceClass, BinarySensorEntity, ) @@ -25,16 +21,14 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER +from .const import CONF_COMMAND_TIMEOUT, LOGGER from .sensor import CommandSensorData DEFAULT_NAME = "Binary Command Sensor" @@ -44,20 +38,6 @@ DEFAULT_PAYLOAD_OFF = "OFF" SCAN_INTERVAL = timedelta(seconds=60) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_COMMAND): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, - vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } -) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -66,19 +46,8 @@ async def async_setup_platform( ) -> None: """Set up the Command line Binary Sensor.""" - if binary_sensor_config := config: - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml_binary_sensor", - breaks_in_ha_version="2023.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_platform_yaml", - translation_placeholders={"platform": BINARY_SENSOR_DOMAIN}, - ) - if discovery_info: - binary_sensor_config = discovery_info + discovery_info = cast(DiscoveryInfoType, discovery_info) + binary_sensor_config = discovery_info name: str = binary_sensor_config.get(CONF_NAME, DEFAULT_NAME) command: str = binary_sensor_config[CONF_COMMAND] diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 2aa67cec641..6b413712ed7 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -3,59 +3,32 @@ from __future__ import annotations import asyncio from datetime import timedelta -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast -import voluptuous as vol - -from homeassistant.components.cover import ( - DOMAIN as COVER_DOMAIN, - PLATFORM_SCHEMA, - CoverEntity, -) +from homeassistant.components.cover import CoverEntity from homeassistant.const import ( CONF_COMMAND_CLOSE, CONF_COMMAND_OPEN, CONF_COMMAND_STATE, CONF_COMMAND_STOP, - CONF_COVERS, - CONF_FRIENDLY_NAME, CONF_NAME, CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER +from .const import CONF_COMMAND_TIMEOUT, LOGGER from .utils import call_shell_with_timeout, check_output_or_log SCAN_INTERVAL = timedelta(seconds=15) -COVER_SCHEMA = vol.Schema( - { - vol.Optional(CONF_COMMAND_CLOSE, default="true"): cv.string, - vol.Optional(CONF_COMMAND_OPEN, default="true"): cv.string, - vol.Optional(CONF_COMMAND_STATE): cv.string, - vol.Optional(CONF_COMMAND_STOP, default="true"): cv.string, - vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } -) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA)} -) - async def async_setup_platform( hass: HomeAssistant, @@ -66,31 +39,14 @@ async def async_setup_platform( """Set up cover controlled by shell commands.""" covers = [] - if discovery_info: - entities: dict[str, Any] = {slugify(discovery_info[CONF_NAME]): discovery_info} - else: - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml_cover", - breaks_in_ha_version="2023.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_platform_yaml", - translation_placeholders={"platform": COVER_DOMAIN}, - ) - entities = config.get(CONF_COVERS, {}) + discovery_info = cast(DiscoveryInfoType, discovery_info) + entities: dict[str, Any] = {slugify(discovery_info[CONF_NAME]): discovery_info} for device_name, device_config in entities.items(): value_template: Template | None = device_config.get(CONF_VALUE_TEMPLATE) if value_template is not None: value_template.hass = hass - if name := device_config.get( - CONF_FRIENDLY_NAME - ): # Backward compatibility. Can be removed after deprecation - device_config[CONF_NAME] = name - trigger_entity_config = { CONF_UNIQUE_ID: device_config.get(CONF_UNIQUE_ID), CONF_NAME: Template(device_config.get(CONF_NAME, device_name), hass), @@ -109,10 +65,6 @@ async def async_setup_platform( ) ) - if not covers: - LOGGER.error("No covers added") - return - async_add_entities(covers) diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py index d00926eb0ee..f61e9959af9 100644 --- a/homeassistant/components/command_line/notify.py +++ b/homeassistant/components/command_line/notify.py @@ -3,34 +3,18 @@ from __future__ import annotations import logging import subprocess -from typing import Any +from typing import Any, cast -import voluptuous as vol - -from homeassistant.components.notify import ( - DOMAIN as NOTIFY_DOMAIN, - PLATFORM_SCHEMA, - BaseNotificationService, -) -from homeassistant.const import CONF_COMMAND, CONF_NAME +from homeassistant.components.notify import BaseNotificationService +from homeassistant.const import CONF_COMMAND from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.process import kill_subprocess -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN +from .const import CONF_COMMAND_TIMEOUT _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_COMMAND): cv.string, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - } -) - def get_service( hass: HomeAssistant, @@ -38,19 +22,9 @@ def get_service( discovery_info: DiscoveryInfoType | None = None, ) -> CommandLineNotificationService: """Get the Command Line notification service.""" - if notify_config := config: - create_issue( - hass, - DOMAIN, - "deprecated_yaml_notify", - breaks_in_ha_version="2023.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_platform_yaml", - translation_placeholders={"platform": NOTIFY_DOMAIN}, - ) - if discovery_info: - notify_config = discovery_info + + discovery_info = cast(DiscoveryInfoType, discovery_info) + notify_config = discovery_info command: str = notify_config[CONF_COMMAND] timeout: int = notify_config[CONF_COMMAND_TIMEOUT] diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index a617d348c8d..99390e77357 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -7,16 +7,7 @@ from datetime import timedelta import json from typing import Any, cast -import voluptuous as vol - -from homeassistant.components.sensor import ( - CONF_STATE_CLASS, - DEVICE_CLASSES_SCHEMA, - DOMAIN as SENSOR_DOMAIN, - PLATFORM_SCHEMA, - STATE_CLASSES_SCHEMA, - SensorDeviceClass, -) +from homeassistant.components.sensor import CONF_STATE_CLASS, SensorDeviceClass from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( CONF_COMMAND, @@ -30,10 +21,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, @@ -43,7 +32,7 @@ from homeassistant.helpers.trigger_template_entity import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER +from .const import CONF_COMMAND_TIMEOUT, LOGGER from .utils import check_output_or_log CONF_JSON_ATTRIBUTES = "json_attributes" @@ -62,20 +51,6 @@ TRIGGER_ENTITY_OPTIONS = ( SCAN_INTERVAL = timedelta(seconds=60) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_COMMAND): cv.string, - vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - vol.Optional(CONF_JSON_ATTRIBUTES): cv.ensure_list_csv, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, - } -) - async def async_setup_platform( hass: HomeAssistant, @@ -84,19 +59,9 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Command Sensor.""" - if sensor_config := config: - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml_sensor", - breaks_in_ha_version="2023.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_platform_yaml", - translation_placeholders={"platform": SENSOR_DOMAIN}, - ) - if discovery_info: - sensor_config = discovery_info + + discovery_info = cast(DiscoveryInfoType, discovery_info) + sensor_config = discovery_info name: str = sensor_config[CONF_NAME] command: str = sensor_config[CONF_COMMAND] diff --git a/homeassistant/components/command_line/strings.json b/homeassistant/components/command_line/strings.json index 9fc0de2ab28..377ed7927aa 100644 --- a/homeassistant/components/command_line/strings.json +++ b/homeassistant/components/command_line/strings.json @@ -1,10 +1,4 @@ { - "issues": { - "deprecated_platform_yaml": { - "title": "Command Line YAML configuration has moved", - "description": "Configuring Command Line `{platform}` using YAML has moved.\n\nConsult the documentation to move your YAML configuration to integration key and restart Home Assistant to fix this issue." - } - }, "services": { "reload": { "name": "[%key:common::action::reload%]", diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 004a65643bb..8d30de310ef 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -3,61 +3,32 @@ from __future__ import annotations import asyncio from datetime import timedelta -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast -import voluptuous as vol - -from homeassistant.components.switch import ( - DOMAIN as SWITCH_DOMAIN, - ENTITY_ID_FORMAT, - PLATFORM_SCHEMA, - SwitchEntity, -) +from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity from homeassistant.const import ( CONF_COMMAND_OFF, CONF_COMMAND_ON, CONF_COMMAND_STATE, - CONF_FRIENDLY_NAME, CONF_ICON, - CONF_ICON_TEMPLATE, CONF_NAME, CONF_SCAN_INTERVAL, - CONF_SWITCHES, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER +from .const import CONF_COMMAND_TIMEOUT, LOGGER from .utils import call_shell_with_timeout, check_output_or_log SCAN_INTERVAL = timedelta(seconds=30) -SWITCH_SCHEMA = vol.Schema( - { - vol.Optional(CONF_COMMAND_OFF, default="true"): cv.string, - vol.Optional(CONF_COMMAND_ON, default="true"): cv.string, - vol.Optional(CONF_COMMAND_STATE): cv.string, - vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_ICON_TEMPLATE): cv.template, - vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } -) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(SWITCH_SCHEMA)} -) - async def async_setup_platform( hass: HomeAssistant, @@ -67,34 +38,12 @@ async def async_setup_platform( ) -> None: """Find and return switches controlled by shell commands.""" - if discovery_info: - entities: dict[str, Any] = {slugify(discovery_info[CONF_NAME]): discovery_info} - else: - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml_switch", - breaks_in_ha_version="2023.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_platform_yaml", - translation_placeholders={"platform": SWITCH_DOMAIN}, - ) - entities = config.get(CONF_SWITCHES, {}) + discovery_info = cast(DiscoveryInfoType, discovery_info) + entities: dict[str, Any] = {slugify(discovery_info[CONF_NAME]): discovery_info} switches = [] for object_id, device_config in entities.items(): - if name := device_config.get( - CONF_FRIENDLY_NAME - ): # Backward compatibility. Can be removed after deprecation - device_config[CONF_NAME] = name - - if icon := device_config.get( - CONF_ICON_TEMPLATE - ): # Backward compatibility. Can be removed after deprecation - device_config[CONF_ICON] = icon - trigger_entity_config = { CONF_UNIQUE_ID: device_config.get(CONF_UNIQUE_ID), CONF_NAME: Template(device_config.get(CONF_NAME, object_id), hass), @@ -119,10 +68,6 @@ async def async_setup_platform( ) ) - if not switches: - LOGGER.error("No switches added") - return - async_add_entities(switches) diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py index 9771e12f1d6..4c64028874d 100644 --- a/homeassistant/components/config/core.py +++ b/homeassistant/components/config/core.py @@ -7,9 +7,8 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.http import HomeAssistantView, require_admin from homeassistant.components.sensor import async_update_suggested_units -from homeassistant.config import async_check_ha_config_file from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import check_config, config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import location, unit_system @@ -31,11 +30,18 @@ class CheckConfigView(HomeAssistantView): @require_admin async def post(self, request): """Validate configuration and return results.""" - errors = await async_check_ha_config_file(request.app["hass"]) - state = "invalid" if errors else "valid" + res = await check_config.async_check_ha_config_file(request.app["hass"]) - return self.json({"result": state, "errors": errors}) + state = "invalid" if res.errors else "valid" + + return self.json( + { + "result": state, + "errors": res.error_str or None, + "warnings": res.warning_str or None, + } + ) @websocket_api.require_admin diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 09245fde8dc..99ebb4b60b1 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -188,11 +188,14 @@ class DefaultAgent(AbstractConversationAgent): return None slot_lists = self._make_slot_lists() + intent_context = self._make_intent_context(user_input) + result = await self.hass.async_add_executor_job( self._recognize, user_input, lang_intents, slot_lists, + intent_context, ) return result @@ -221,15 +224,17 @@ class DefaultAgent(AbstractConversationAgent): # loaded in async_recognize. assert lang_intents is not None + # Slot values to pass to the intent + slots = { + entity.name: {"value": entity.value} for entity in result.entities_list + } + try: intent_response = await intent.async_handle( self.hass, DOMAIN, result.intent.name, - { - entity.name: {"value": entity.value} - for entity in result.entities_list - }, + slots, user_input.text, user_input.context, language, @@ -277,12 +282,16 @@ class DefaultAgent(AbstractConversationAgent): user_input: ConversationInput, lang_intents: LanguageIntents, slot_lists: dict[str, SlotList], + intent_context: dict[str, Any] | None, ) -> RecognizeResult | None: """Search intents for a match to user input.""" # Prioritize matches with entity names above area names maybe_result: RecognizeResult | None = None for result in recognize_all( - user_input.text, lang_intents.intents, slot_lists=slot_lists + user_input.text, + lang_intents.intents, + slot_lists=slot_lists, + intent_context=intent_context, ): if "name" in result.entities: return result @@ -368,10 +377,11 @@ class DefaultAgent(AbstractConversationAgent): async def async_reload(self, language: str | None = None): """Clear cached intents for a language.""" if language is None: - language = self.hass.config.language - - self._lang_intents.pop(language, None) - _LOGGER.debug("Cleared intents for language: %s", language) + self._lang_intents.clear() + _LOGGER.debug("Cleared intents for all languages") + else: + self._lang_intents.pop(language, None) + _LOGGER.debug("Cleared intents for language: %s", language) async def async_prepare(self, language: str | None = None): """Load intents for a language.""" @@ -622,6 +632,25 @@ class DefaultAgent(AbstractConversationAgent): return self._slot_lists + def _make_intent_context( + self, user_input: ConversationInput + ) -> dict[str, Any] | None: + """Return intent recognition context for user input.""" + if not user_input.device_id: + return None + + devices = dr.async_get(self.hass) + device = devices.async_get(user_input.device_id) + if (device is None) or (device.area_id is None): + return None + + areas = ar.async_get(self.hass) + device_area = areas.async_get_area(device.area_id) + if device_area is None: + return None + + return {"area": device_area.name} + def _get_error_text( self, response_type: ResponseType, lang_intents: LanguageIntents | None ) -> str: diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 1b4d346082a..cb03499d8e4 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.2.5", "home-assistant-intents==2023.10.16"] + "requirements": ["hassil==1.5.1", "home-assistant-intents==2023.12.05"] } diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index 71ddb5c1237..30cc9a0d5d0 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -26,11 +26,23 @@ def has_no_punctuation(value: list[str]) -> list[str]: return value +def has_one_non_empty_item(value: list[str]) -> list[str]: + """Validate result has at least one item.""" + if len(value) < 1: + raise vol.Invalid("at least one sentence is required") + + for sentence in value: + if not sentence: + raise vol.Invalid(f"sentence too short: '{sentence}'") + + return value + + TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): DOMAIN, vol.Required(CONF_COMMAND): vol.All( - cv.ensure_list, [cv.string], has_no_punctuation + cv.ensure_list, [cv.string], has_one_non_empty_item, has_no_punctuation ), } ) diff --git a/homeassistant/components/coolmaster/strings.json b/homeassistant/components/coolmaster/strings.json index 7baa6444c1d..17deab306df 100644 --- a/homeassistant/components/coolmaster/strings.json +++ b/homeassistant/components/coolmaster/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Set up your CoolMasterNet connection details.", + "description": "Set up your CoolMasterNet connection details.", "data": { "host": "[%key:common::config_flow::data::host%]", "off": "Can be turned off", @@ -12,6 +12,9 @@ "dry": "Support dry mode", "fan_only": "Support fan only mode", "swing_support": "Control swing mode" + }, + "data_description": { + "host": "The hostname or IP address of your CoolMasterNet device." } } }, diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index f946f29bdaa..42676498c9f 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -18,7 +18,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType @@ -44,7 +43,6 @@ ENTITY_ID_FORMAT = DOMAIN + ".{}" SERVICE_DECREMENT = "decrement" SERVICE_INCREMENT = "increment" SERVICE_RESET = "reset" -SERVICE_CONFIGURE = "configure" SERVICE_SET_VALUE = "set_value" STORAGE_KEY = DOMAIN @@ -131,17 +129,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: {vol.Required(VALUE): cv.positive_int}, "async_set_value", ) - component.async_register_entity_service( - SERVICE_CONFIGURE, - { - vol.Optional(ATTR_MINIMUM): vol.Any(None, vol.Coerce(int)), - vol.Optional(ATTR_MAXIMUM): vol.Any(None, vol.Coerce(int)), - vol.Optional(ATTR_STEP): cv.positive_int, - vol.Optional(ATTR_INITIAL): cv.positive_int, - vol.Optional(VALUE): cv.positive_int, - }, - "async_configure", - ) return True @@ -285,25 +272,6 @@ class Counter(collection.CollectionEntity, RestoreEntity): self._state = value self.async_write_ha_state() - @callback - def async_configure(self, **kwargs) -> None: - """Change the counter's settings with a service.""" - async_create_issue( - self.hass, - DOMAIN, - "deprecated_configure_service", - breaks_in_ha_version="2023.12.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_configure_service", - ) - - new_state = kwargs.pop(VALUE, self._state) - self._config = {**self._config, **kwargs} - self._state = self.compute_next_state(new_state) - self.async_write_ha_state() - async def async_update_config(self, config: ConfigType) -> None: """Change the counter's settings WS CRUD.""" self._config = config diff --git a/homeassistant/components/counter/reproduce_state.py b/homeassistant/components/counter/reproduce_state.py index 2029321c430..2308e0fb07a 100644 --- a/homeassistant/components/counter/reproduce_state.py +++ b/homeassistant/components/counter/reproduce_state.py @@ -9,15 +9,7 @@ from typing import Any from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import Context, HomeAssistant, State -from . import ( - ATTR_INITIAL, - ATTR_MAXIMUM, - ATTR_MINIMUM, - ATTR_STEP, - DOMAIN, - SERVICE_CONFIGURE, - VALUE, -) +from . import ATTR_MAXIMUM, ATTR_MINIMUM, ATTR_STEP, DOMAIN, SERVICE_SET_VALUE, VALUE _LOGGER = logging.getLogger(__name__) @@ -43,7 +35,6 @@ async def _async_reproduce_state( # Return if we are already at the right state. if ( cur_state.state == state.state - and cur_state.attributes.get(ATTR_INITIAL) == state.attributes.get(ATTR_INITIAL) and cur_state.attributes.get(ATTR_MAXIMUM) == state.attributes.get(ATTR_MAXIMUM) and cur_state.attributes.get(ATTR_MINIMUM) == state.attributes.get(ATTR_MINIMUM) and cur_state.attributes.get(ATTR_STEP) == state.attributes.get(ATTR_STEP) @@ -51,9 +42,7 @@ async def _async_reproduce_state( return service_data = {ATTR_ENTITY_ID: state.entity_id, VALUE: state.state} - service = SERVICE_CONFIGURE - if ATTR_INITIAL in state.attributes: - service_data[ATTR_INITIAL] = state.attributes[ATTR_INITIAL] + service = SERVICE_SET_VALUE if ATTR_MAXIMUM in state.attributes: service_data[ATTR_MAXIMUM] = state.attributes[ATTR_MAXIMUM] if ATTR_MINIMUM in state.attributes: diff --git a/homeassistant/components/counter/strings.json b/homeassistant/components/counter/strings.json index 53c87349836..fb1f6467f4a 100644 --- a/homeassistant/components/counter/strings.json +++ b/homeassistant/components/counter/strings.json @@ -26,19 +26,6 @@ } } }, - "issues": { - "deprecated_configure_service": { - "title": "The counter configure service is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::counter::issues::deprecated_configure_service::title%]", - "description": "The counter service `counter.configure` is being removed and use of it has been detected. If you want to change the current value of a counter, use the new `counter.set_value` service instead.\n\nPlease remove this service from your automations and scripts and select **submit** to close this issue." - } - } - } - } - }, "services": { "decrement": { "name": "Decrement", diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 8dd75916685..7acd234e397 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -13,8 +13,10 @@ from . import DOMAIN as DAIKIN_DOMAIN, DaikinApi ZONE_ICON = "mdi:home-circle" STREAMER_ICON = "mdi:air-filter" +TOGGLE_ICON = "mdi:power" DAIKIN_ATTR_ADVANCED = "adv" DAIKIN_ATTR_STREAMER = "streamer" +DAIKIN_ATTR_MODE = "mode" async def async_setup_platform( @@ -35,7 +37,7 @@ async def async_setup_entry( ) -> None: """Set up Daikin climate based on config_entry.""" daikin_api: DaikinApi = hass.data[DAIKIN_DOMAIN][entry.entry_id] - switches: list[DaikinZoneSwitch | DaikinStreamerSwitch] = [] + switches: list[DaikinZoneSwitch | DaikinStreamerSwitch | DaikinToggleSwitch] = [] if zones := daikin_api.device.zones: switches.extend( [ @@ -49,6 +51,7 @@ async def async_setup_entry( # device supports the streamer, so assume so if it does support # advanced modes. switches.append(DaikinStreamerSwitch(daikin_api)) + switches.append(DaikinToggleSwitch(daikin_api)) async_add_entities(switches) @@ -119,3 +122,33 @@ class DaikinStreamerSwitch(SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the zone off.""" await self._api.device.set_streamer("off") + + +class DaikinToggleSwitch(SwitchEntity): + """Switch state.""" + + _attr_icon = TOGGLE_ICON + _attr_has_entity_name = True + + def __init__(self, api: DaikinApi) -> None: + """Initialize switch.""" + self._api = api + self._attr_device_info = api.device_info + self._attr_unique_id = f"{self._api.device.mac}-toggle" + + @property + def is_on(self) -> bool: + """Return the state of the sensor.""" + return "off" not in self._api.device.represent(DAIKIN_ATTR_MODE) + + async def async_update(self) -> None: + """Retrieve latest state.""" + await self._api.async_update() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the zone on.""" + await self._api.device.set({}) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the zone off.""" + await self._api.device.set({DAIKIN_ATTR_MODE: "off"}) diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index 114e401346d..84141eac964 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -65,24 +65,15 @@ T = TypeVar( ) -@dataclass -class DeconzBinarySensorDescriptionMixin(Generic[T]): - """Required values when describing secondary sensor attributes.""" - - update_key: str - value_fn: Callable[[T], bool | None] - - -@dataclass -class DeconzBinarySensorDescription( - BinarySensorEntityDescription, - DeconzBinarySensorDescriptionMixin[T], -): +@dataclass(kw_only=True) +class DeconzBinarySensorDescription(Generic[T], BinarySensorEntityDescription): """Class describing deCONZ binary sensor entities.""" instance_check: type[T] | None = None name_suffix: str = "" old_unique_id_suffix: str = "" + update_key: str + value_fn: Callable[[T], bool | None] ENTITY_DESCRIPTIONS: tuple[DeconzBinarySensorDescription, ...] = ( diff --git a/homeassistant/components/deconz/button.py b/homeassistant/components/deconz/button.py index 318e0e43beb..81d839ea0f2 100644 --- a/homeassistant/components/deconz/button.py +++ b/homeassistant/components/deconz/button.py @@ -23,18 +23,13 @@ from .deconz_device import DeconzDevice, DeconzSceneMixin from .gateway import DeconzGateway, get_gateway_from_config_entry -@dataclass -class DeconzButtonDescriptionMixin: - """Required values when describing deCONZ button entities.""" - - suffix: str - button_fn: str - - -@dataclass -class DeconzButtonDescription(ButtonEntityDescription, DeconzButtonDescriptionMixin): +@dataclass(kw_only=True) +class DeconzButtonDescription(ButtonEntityDescription): """Class describing deCONZ button entities.""" + button_fn: str + suffix: str + ENTITY_DESCRIPTIONS = { PydeconzScene: [ diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/deconz_device.py index 4c0f35266f9..8a5ced2c678 100644 --- a/homeassistant/components/deconz/deconz_device.py +++ b/homeassistant/components/deconz/deconz_device.py @@ -129,9 +129,8 @@ class DeconzDevice(DeconzBase[_DeviceT], Entity): if self.gateway.ignore_state_updates: return - if ( - self._update_keys is not None - and not self._device.changed_keys.intersection(self._update_keys) + if self._update_keys is not None and not self._device.changed_keys.intersection( + self._update_keys ): return diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 6245558a1c5..af1824e441c 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pydeconz"], "quality_scale": "platinum", - "requirements": ["pydeconz==113"], + "requirements": ["pydeconz==114"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/homeassistant/components/deconz/number.py b/homeassistant/components/deconz/number.py index ec4438502b6..7cc0da936cb 100644 --- a/homeassistant/components/deconz/number.py +++ b/homeassistant/components/deconz/number.py @@ -31,9 +31,9 @@ from .util import serial_from_unique_id T = TypeVar("T", Presence, PydeconzSensorBase) -@dataclass -class DeconzNumberDescriptionMixin(Generic[T]): - """Required values when describing deCONZ number entities.""" +@dataclass(kw_only=True) +class DeconzNumberDescription(Generic[T], NumberEntityDescription): + """Class describing deCONZ number entities.""" instance_check: type[T] name_suffix: str @@ -42,11 +42,6 @@ class DeconzNumberDescriptionMixin(Generic[T]): value_fn: Callable[[T], float | None] -@dataclass -class DeconzNumberDescription(NumberEntityDescription, DeconzNumberDescriptionMixin[T]): - """Class describing deCONZ number entities.""" - - ENTITY_DESCRIPTIONS: tuple[DeconzNumberDescription, ...] = ( DeconzNumberDescription[Presence]( key="delay", diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 4e00ac0a415..ecb9ac9b297 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -17,6 +17,7 @@ from pydeconz.models.sensor.generic_status import GenericStatus from pydeconz.models.sensor.humidity import Humidity from pydeconz.models.sensor.light_level import LightLevel from pydeconz.models.sensor.moisture import Moisture +from pydeconz.models.sensor.particulate_matter import ParticulateMatter from pydeconz.models.sensor.power import Power from pydeconz.models.sensor.pressure import Pressure from pydeconz.models.sensor.switch import Switch @@ -83,6 +84,7 @@ T = TypeVar( Humidity, LightLevel, Moisture, + ParticulateMatter, Power, Pressure, Temperature, @@ -91,22 +93,16 @@ T = TypeVar( ) -@dataclass -class DeconzSensorDescriptionMixin(Generic[T]): - """Required values when describing secondary sensor attributes.""" - - supported_fn: Callable[[T], bool] - update_key: str - value_fn: Callable[[T], datetime | StateType] - - -@dataclass -class DeconzSensorDescription(SensorEntityDescription, DeconzSensorDescriptionMixin[T]): +@dataclass(kw_only=True) +class DeconzSensorDescription(Generic[T], SensorEntityDescription): """Class describing deCONZ binary sensor entities.""" instance_check: type[T] | None = None name_suffix: str = "" old_unique_id_suffix: str = "" + supported_fn: Callable[[T], bool] + update_key: str + value_fn: Callable[[T], datetime | StateType] ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = ( @@ -219,6 +215,17 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, suggested_display_precision=1, ), + DeconzSensorDescription[ParticulateMatter]( + key="particulate_matter_pm2_5", + supported_fn=lambda device: device.measured_value is not None, + update_key="measured_value", + value_fn=lambda device: device.measured_value, + instance_check=ParticulateMatter, + name_suffix="PM25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), DeconzSensorDescription[Power]( key="power", supported_fn=lambda device: device.power is not None, diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index e32ab875c28..c06a07e6ce5 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -11,11 +11,14 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your deCONZ host." } }, "link": { "title": "Link with deCONZ", - "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings -> Gateway -> Advanced\n2. Press \"Authenticate app\" button" + "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings > Gateway > Advanced\n2. Press \"Authenticate app\" button" }, "hassio_confirm": { "title": "deCONZ Zigbee gateway via Home Assistant add-on", diff --git a/homeassistant/components/deluge/strings.json b/homeassistant/components/deluge/strings.json index e0266d004e2..52706f39894 100644 --- a/homeassistant/components/deluge/strings.json +++ b/homeassistant/components/deluge/strings.json @@ -9,6 +9,9 @@ "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]", "web_port": "Web port (for visiting service)" + }, + "data_description": { + "host": "The hostname or IP address of your Deluge device." } } }, diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py index 211389a5466..73cae4a64b1 100644 --- a/homeassistant/components/demo/fan.py +++ b/homeassistant/components/demo/fan.py @@ -161,12 +161,9 @@ class DemoPercentageFan(BaseDemoFan, FanEntity): def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - if self.preset_modes and preset_mode in self.preset_modes: - self._preset_mode = preset_mode - self._percentage = None - self.schedule_update_ha_state() - else: - raise ValueError(f"Invalid preset mode: {preset_mode}") + self._preset_mode = preset_mode + self._percentage = None + self.schedule_update_ha_state() def turn_on( self, @@ -230,10 +227,6 @@ class AsyncDemoPercentageFan(BaseDemoFan, FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - if self.preset_modes is None or preset_mode not in self.preset_modes: - raise ValueError( - f"{preset_mode} is not a valid preset_mode: {self.preset_modes}" - ) self._preset_mode = preset_mode self._percentage = None self.async_write_ha_state() diff --git a/homeassistant/components/devialet/__init__.py b/homeassistant/components/devialet/__init__.py new file mode 100644 index 00000000000..034f93abb68 --- /dev/null +++ b/homeassistant/components/devialet/__init__.py @@ -0,0 +1,31 @@ +"""The Devialet integration.""" +from __future__ import annotations + +from devialet import DevialetApi + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +PLATFORMS = [Platform.MEDIA_PLAYER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Devialet from a config entry.""" + session = async_get_clientsession(hass) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DevialetApi( + entry.data[CONF_HOST], session + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Devialet config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + del hass.data[DOMAIN][entry.entry_id] + return unload_ok diff --git a/homeassistant/components/devialet/config_flow.py b/homeassistant/components/devialet/config_flow.py new file mode 100644 index 00000000000..de52788de50 --- /dev/null +++ b/homeassistant/components/devialet/config_flow.py @@ -0,0 +1,104 @@ +"""Support for Devialet Phantom speakers.""" +from __future__ import annotations + +import logging +from typing import Any + +from devialet.devialet_api import DevialetApi +import voluptuous as vol + +from homeassistant.components import zeroconf +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +LOGGER = logging.getLogger(__package__) + + +class DevialetFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow for Devialet.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize flow.""" + self._host: str | None = None + self._name: str | None = None + self._model: str | None = None + self._serial: str | None = None + self._errors: dict[str, str] = {} + + async def async_validate_input(self) -> FlowResult | None: + """Validate the input using the Devialet API.""" + + self._errors.clear() + session = async_get_clientsession(self.hass) + client = DevialetApi(self._host, session) + + if not await client.async_update() or client.serial is None: + self._errors["base"] = "cannot_connect" + LOGGER.error("Cannot connect") + return None + + await self.async_set_unique_id(client.serial) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=client.device_name, + data={CONF_HOST: self._host, CONF_NAME: client.device_name}, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user or zeroconf.""" + + if user_input is not None: + self._host = user_input[CONF_HOST] + result = await self.async_validate_input() + if result is not None: + return result + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=self._errors, + ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle a flow initialized by zeroconf discovery.""" + LOGGER.info("Devialet device found via ZEROCONF: %s", discovery_info) + + self._host = discovery_info.host + self._name = discovery_info.name.split(".", 1)[0] + self._model = discovery_info.properties["model"] + self._serial = discovery_info.properties["serialNumber"] + + await self.async_set_unique_id(self._serial) + self._abort_if_unique_id_configured() + + self.context["title_placeholders"] = {"title": self._name} + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle user-confirmation of discovered node.""" + title = f"{self._name} ({self._model})" + + if user_input is not None: + result = await self.async_validate_input() + if result is not None: + return result + + return self.async_show_form( + step_id="confirm", + description_placeholders={"device": self._model, "title": title}, + errors=self._errors, + last_step=True, + ) diff --git a/homeassistant/components/devialet/const.py b/homeassistant/components/devialet/const.py new file mode 100644 index 00000000000..ccb4fbc7964 --- /dev/null +++ b/homeassistant/components/devialet/const.py @@ -0,0 +1,12 @@ +"""Constants for the Devialet integration.""" +from typing import Final + +DOMAIN: Final = "devialet" +MANUFACTURER: Final = "Devialet" + +SOUND_MODES = { + "Custom": "custom", + "Flat": "flat", + "Night mode": "night mode", + "Voice": "voice", +} diff --git a/homeassistant/components/devialet/coordinator.py b/homeassistant/components/devialet/coordinator.py new file mode 100644 index 00000000000..9e1eada7183 --- /dev/null +++ b/homeassistant/components/devialet/coordinator.py @@ -0,0 +1,32 @@ +"""Class representing a Devialet update coordinator.""" +from datetime import timedelta +import logging + +from devialet import DevialetApi + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=5) + + +class DevialetCoordinator(DataUpdateCoordinator[None]): + """Devialet update coordinator.""" + + def __init__(self, hass: HomeAssistant, client: DevialetApi) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.client = client + + async def _async_update_data(self) -> None: + """Fetch data from API endpoint.""" + await self.client.async_update() diff --git a/homeassistant/components/devialet/diagnostics.py b/homeassistant/components/devialet/diagnostics.py new file mode 100644 index 00000000000..f9824a9cad1 --- /dev/null +++ b/homeassistant/components/devialet/diagnostics.py @@ -0,0 +1,20 @@ +"""Diagnostics support for Devialet.""" +from __future__ import annotations + +from typing import Any + +from devialet import DevialetApi + +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: DevialetApi = hass.data[DOMAIN][entry.entry_id] + + return await client.async_get_diagnostics() diff --git a/homeassistant/components/devialet/manifest.json b/homeassistant/components/devialet/manifest.json new file mode 100644 index 00000000000..286b9bfb112 --- /dev/null +++ b/homeassistant/components/devialet/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "devialet", + "name": "Devialet", + "after_dependencies": ["zeroconf"], + "codeowners": ["@fwestenberg"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/devialet", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["devialet==1.4.3"], + "zeroconf": ["_devialet-http._tcp.local."] +} diff --git a/homeassistant/components/devialet/media_player.py b/homeassistant/components/devialet/media_player.py new file mode 100644 index 00000000000..a79a82e6f60 --- /dev/null +++ b/homeassistant/components/devialet/media_player.py @@ -0,0 +1,212 @@ +"""Support for Devialet speakers.""" +from __future__ import annotations + +from devialet.const import NORMAL_INPUTS + +from homeassistant.components.media_player import ( + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER, SOUND_MODES +from .coordinator import DevialetCoordinator + +SUPPORT_DEVIALET = ( + MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.SELECT_SOUND_MODE +) + +DEVIALET_TO_HA_FEATURE_MAP = { + "play": MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.STOP, + "pause": MediaPlayerEntityFeature.PAUSE, + "previous": MediaPlayerEntityFeature.PREVIOUS_TRACK, + "next": MediaPlayerEntityFeature.NEXT_TRACK, + "seek": MediaPlayerEntityFeature.SEEK, +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Devialet entry.""" + client = hass.data[DOMAIN][entry.entry_id] + coordinator = DevialetCoordinator(hass, client) + await coordinator.async_config_entry_first_refresh() + + async_add_entities([DevialetMediaPlayerEntity(coordinator, entry)]) + + +class DevialetMediaPlayerEntity( + CoordinatorEntity[DevialetCoordinator], MediaPlayerEntity +): + """Devialet media player.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, coordinator: DevialetCoordinator, entry: ConfigEntry) -> None: + """Initialize the Devialet device.""" + self.coordinator = coordinator + super().__init__(coordinator) + + self._attr_unique_id = str(entry.unique_id) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer=MANUFACTURER, + model=self.coordinator.client.model, + name=entry.data[CONF_NAME], + sw_version=self.coordinator.client.version, + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if not self.coordinator.client.is_available: + self.async_write_ha_state() + return + + self._attr_volume_level = self.coordinator.client.volume_level + self._attr_is_volume_muted = self.coordinator.client.is_volume_muted + self._attr_source_list = self.coordinator.client.source_list + self._attr_sound_mode_list = sorted(SOUND_MODES) + self._attr_media_artist = self.coordinator.client.media_artist + self._attr_media_album_name = self.coordinator.client.media_album_name + self._attr_media_artist = self.coordinator.client.media_artist + self._attr_media_image_url = self.coordinator.client.media_image_url + self._attr_media_duration = self.coordinator.client.media_duration + self._attr_media_position = self.coordinator.client.current_position + self._attr_media_position_updated_at = ( + self.coordinator.client.position_updated_at + ) + self._attr_media_title = ( + self.coordinator.client.media_title + if self.coordinator.client.media_title + else self.source + ) + self.async_write_ha_state() + + @property + def state(self) -> MediaPlayerState | None: + """Return the state of the device.""" + playing_state = self.coordinator.client.playing_state + + if not playing_state: + return MediaPlayerState.IDLE + if playing_state == "playing": + return MediaPlayerState.PLAYING + if playing_state == "paused": + return MediaPlayerState.PAUSED + return MediaPlayerState.ON + + @property + def available(self) -> bool: + """Return if the media player is available.""" + return self.coordinator.client.is_available + + @property + def supported_features(self) -> MediaPlayerEntityFeature: + """Flag media player features that are supported.""" + features = SUPPORT_DEVIALET + + if self.coordinator.client.source_state is None: + return features + + if not self.coordinator.client.available_options: + return features + + for option in self.coordinator.client.available_options: + features |= DEVIALET_TO_HA_FEATURE_MAP.get(option, 0) + return features + + @property + def source(self) -> str | None: + """Return the current input source.""" + source = self.coordinator.client.source + + for pretty_name, name in NORMAL_INPUTS.items(): + if source == name: + return pretty_name + return None + + @property + def sound_mode(self) -> str | None: + """Return the current sound mode.""" + if self.coordinator.client.equalizer is not None: + sound_mode = self.coordinator.client.equalizer + elif self.coordinator.client.night_mode: + sound_mode = "night mode" + else: + return None + + for pretty_name, mode in SOUND_MODES.items(): + if sound_mode == mode: + return pretty_name + return None + + async def async_volume_up(self) -> None: + """Volume up media player.""" + await self.coordinator.client.async_volume_up() + + async def async_volume_down(self) -> None: + """Volume down media player.""" + await self.coordinator.client.async_volume_down() + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + await self.coordinator.client.async_set_volume_level(volume) + + async def async_mute_volume(self, mute: bool) -> None: + """Mute (true) or unmute (false) media player.""" + await self.coordinator.client.async_mute_volume(mute) + + async def async_media_play(self) -> None: + """Play media player.""" + await self.coordinator.client.async_media_play() + + async def async_media_pause(self) -> None: + """Pause media player.""" + await self.coordinator.client.async_media_pause() + + async def async_media_stop(self) -> None: + """Pause media player.""" + await self.coordinator.client.async_media_stop() + + async def async_media_next_track(self) -> None: + """Send the next track command.""" + await self.coordinator.client.async_media_next_track() + + async def async_media_previous_track(self) -> None: + """Send the previous track command.""" + await self.coordinator.client.async_media_previous_track() + + async def async_media_seek(self, position: float) -> None: + """Send seek command.""" + await self.coordinator.client.async_media_seek(position) + + async def async_select_sound_mode(self, sound_mode: str) -> None: + """Send sound mode command.""" + for pretty_name, mode in SOUND_MODES.items(): + if sound_mode == pretty_name: + if mode == "night mode": + await self.coordinator.client.async_set_night_mode(True) + else: + await self.coordinator.client.async_set_night_mode(False) + await self.coordinator.client.async_set_equalizer(mode) + + async def async_turn_off(self) -> None: + """Turn off media player.""" + await self.coordinator.client.async_turn_off() + + async def async_select_source(self, source: str) -> None: + """Select input source.""" + await self.coordinator.client.async_select_source(source) diff --git a/homeassistant/components/devialet/strings.json b/homeassistant/components/devialet/strings.json new file mode 100644 index 00000000000..0a90da49bf4 --- /dev/null +++ b/homeassistant/components/devialet/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "flow_title": "{title}", + "step": { + "user": { + "description": "Please enter the host name or IP address of the Devialet device.", + "data": { + "host": "Host" + } + }, + "confirm": { + "description": "Do you want to set up Devialet device {device}?" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + } +} diff --git a/homeassistant/components/devialet/translations/en.json b/homeassistant/components/devialet/translations/en.json new file mode 100644 index 00000000000..af0cfc4c122 --- /dev/null +++ b/homeassistant/components/devialet/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Service is already configured" + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "flow_title": "{title}", + "step": { + "confirm": { + "description": "Do you want to set up Devialet device {device}?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Please enter the host name or IP address of the Devialet device." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 7c12a2d8777..c931d256689 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant import util from homeassistant.backports.functools import cached_property from homeassistant.components import zone -from homeassistant.config import async_log_exception, load_yaml_config_file +from homeassistant.config import async_log_schema_error, load_yaml_config_file from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_GPS_ACCURACY, @@ -44,7 +44,11 @@ from homeassistant.helpers.event import ( ) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, GPSType, StateType -from homeassistant.setup import async_prepare_setup_platform, async_start_setup +from homeassistant.setup import ( + async_notify_setup_error, + async_prepare_setup_platform, + async_start_setup, +) from homeassistant.util import dt as dt_util from homeassistant.util.yaml import dump @@ -1006,7 +1010,8 @@ async def async_load_config( device = dev_schema(device) device["dev_id"] = cv.slugify(dev_id) except vol.Invalid as exp: - async_log_exception(exp, dev_id, devices, hass) + async_log_schema_error(exp, dev_id, devices, hass) + async_notify_setup_error(hass, DOMAIN) else: result.append(Device(hass, **device)) return result @@ -1028,6 +1033,19 @@ def update_config(path: str, dev_id: str, device: Device) -> None: out.write(dump(device_config)) +def remove_device_from_config(hass: HomeAssistant, device_id: str) -> None: + """Remove device from YAML configuration file.""" + path = hass.config.path(YAML_DEVICES) + devices = load_yaml_config_file(path) + devices.pop(device_id) + dumped = dump(devices) + + with open(path, "r+", encoding="utf8") as out: + out.seek(0) + out.truncate() + out.write(dumped) + + def get_gravatar_for_email(email: str) -> str: """Return an 80px Gravatar for the given email address. diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index 0fee65d57b6..842d1bee40f 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -63,7 +63,8 @@ async def async_setup_entry( # noqa: C901 ) await device.async_connect(session_instance=async_client) device.password = entry.data.get( - CONF_PASSWORD, "" # This key was added in HA Core 2022.6 + CONF_PASSWORD, + "", # This key was added in HA Core 2022.6 ) except DeviceNotFound as err: raise ConfigEntryNotReady( diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index 53c502dc811..a0aa0466d90 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -12,7 +12,7 @@ from devolo_plc_api.device_api import ( from devolo_plc_api.plcnet_api import LogicalNetwork from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -49,6 +49,7 @@ class DevoloEntity(Entity): self._attr_device_info = DeviceInfo( configuration_url=f"http://{device.ip}", + connections={(CONNECTION_NETWORK_MAC, device.mac)}, identifiers={(DOMAIN, str(device.serial_number))}, manufacturer="devolo", model=device.product, diff --git a/homeassistant/components/diagnostics/util.py b/homeassistant/components/diagnostics/util.py index cbb8831e9b5..47a0eac9a0d 100644 --- a/homeassistant/components/diagnostics/util.py +++ b/homeassistant/components/diagnostics/util.py @@ -12,7 +12,7 @@ _T = TypeVar("_T") @overload -def async_redact_data(data: Mapping, to_redact: Iterable[Any]) -> dict: # type: ignore[misc] +def async_redact_data(data: Mapping, to_redact: Iterable[Any]) -> dict: # type: ignore[overload-overlap] ... diff --git a/homeassistant/components/directv/strings.json b/homeassistant/components/directv/strings.json index 8ed52cd3632..2c30e3db85c 100644 --- a/homeassistant/components/directv/strings.json +++ b/homeassistant/components/directv/strings.json @@ -8,6 +8,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your DirectTV device." } } }, diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py index 32f696a04ce..f21a03ef748 100644 --- a/homeassistant/components/discovergy/__init__.py +++ b/homeassistant/components/discovergy/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -import pydiscovergy +from pydiscovergy import Discovergy from pydiscovergy.authentication import BasicAuth import pydiscovergy.error as discovergyError from pydiscovergy.models import Meter @@ -24,7 +24,7 @@ PLATFORMS = [Platform.SENSOR] class DiscovergyData: """Discovergy data class to share meters and api client.""" - api_client: pydiscovergy.Discovergy + api_client: Discovergy meters: list[Meter] coordinators: dict[str, DiscovergyUpdateCoordinator] @@ -35,7 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # init discovergy data class discovergy_data = DiscovergyData( - api_client=pydiscovergy.Discovergy( + api_client=Discovergy( email=entry.data[CONF_EMAIL], password=entry.data[CONF_PASSWORD], httpx_client=get_async_client(hass), diff --git a/homeassistant/components/discovergy/config_flow.py b/homeassistant/components/discovergy/config_flow.py index b3dee2d82a0..38a250a381d 100644 --- a/homeassistant/components/discovergy/config_flow.py +++ b/homeassistant/components/discovergy/config_flow.py @@ -5,7 +5,7 @@ from collections.abc import Mapping import logging from typing import Any -import pydiscovergy +from pydiscovergy import Discovergy from pydiscovergy.authentication import BasicAuth import pydiscovergy.error as discovergyError import voluptuous as vol @@ -70,7 +70,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input: try: - await pydiscovergy.Discovergy( + await Discovergy( email=user_input[CONF_EMAIL], password=user_input[CONF_PASSWORD], httpx_client=get_async_client(self.hass), diff --git a/homeassistant/components/discovergy/coordinator.py b/homeassistant/components/discovergy/coordinator.py index 5f27c6a43d2..5a3448a9e4b 100644 --- a/homeassistant/components/discovergy/coordinator.py +++ b/homeassistant/components/discovergy/coordinator.py @@ -12,17 +12,12 @@ 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__) class DiscovergyUpdateCoordinator(DataUpdateCoordinator[Reading]): """The Discovergy update coordinator.""" - discovergy_client: Discovergy - meter: Meter - def __init__( self, hass: HomeAssistant, @@ -36,7 +31,7 @@ class DiscovergyUpdateCoordinator(DataUpdateCoordinator[Reading]): super().__init__( hass, _LOGGER, - name=DOMAIN, + name=f"Discovergy meter {meter.meter_id}", update_interval=timedelta(seconds=30), ) diff --git a/homeassistant/components/discovergy/diagnostics.py b/homeassistant/components/discovergy/diagnostics.py index e0a9e47e6fd..75c6f97c701 100644 --- a/homeassistant/components/discovergy/diagnostics.py +++ b/homeassistant/components/discovergy/diagnostics.py @@ -4,8 +4,6 @@ from __future__ import annotations from dataclasses import asdict from typing import Any -from pydiscovergy.models import Meter - from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -30,9 +28,8 @@ async def async_get_config_entry_diagnostics( flattened_meter: list[dict] = [] last_readings: dict[str, dict] = {} data: DiscovergyData = hass.data[DOMAIN][entry.entry_id] - meters: list[Meter] = data.meters # always returns a list - for meter in meters: + for meter in data.meters: # make a dict of meter data and redact some data flattened_meter.append(async_redact_data(asdict(meter), TO_REDACT_METER)) diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index 0f5ace28dd7..ed878fbb82e 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -27,25 +27,25 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import DiscovergyData, DiscovergyUpdateCoordinator from .const import DOMAIN, MANUFACTURER -PARALLEL_UPDATES = 1 + +def _get_and_scale(reading: Reading, key: str, scale: int) -> datetime | float | None: + """Get a value from a Reading and divide with scale it.""" + if (value := reading.values.get(key)) is not None: + return value / scale + return None -@dataclass -class DiscovergyMixin: - """Mixin for alternative keys.""" +@dataclass(kw_only=True) +class DiscovergySensorEntityDescription(SensorEntityDescription): + """Class to describe a Discovergy sensor entity.""" value_fn: Callable[[Reading, str, int], datetime | float | None] = field( - default=lambda reading, key, scale: float(reading.values[key] / scale) + default=_get_and_scale ) alternative_keys: list[str] = field(default_factory=lambda: []) scale: int = field(default_factory=lambda: 1000) -@dataclass -class DiscovergySensorEntityDescription(DiscovergyMixin, SensorEntityDescription): - """Define Sensor entity description class.""" - - GAS_SENSORS: tuple[DiscovergySensorEntityDescription, ...] = ( DiscovergySensorEntityDescription( key="volume", @@ -166,37 +166,30 @@ async def async_setup_entry( ) -> None: """Set up the Discovergy sensors.""" data: DiscovergyData = hass.data[DOMAIN][entry.entry_id] - meters: list[Meter] = data.meters # always returns a list entities: list[DiscovergySensor] = [] - for meter in meters: - sensors = None - if meter.measurement_type == "ELECTRICITY": - sensors = ELECTRICITY_SENSORS - elif meter.measurement_type == "GAS": - sensors = GAS_SENSORS - + for meter in data.meters: + sensors: tuple[DiscovergySensorEntityDescription, ...] = () coordinator: DiscovergyUpdateCoordinator = data.coordinators[meter.meter_id] - if sensors is not None: - for description in sensors: - # check if this meter has this data, then add this sensor - for key in {description.key, *description.alternative_keys}: - if key in coordinator.data.values: - entities.append( - DiscovergySensor(key, description, meter, coordinator) - ) + # select sensor descriptions based on meter type and combine with additional sensors + if meter.measurement_type == "ELECTRICITY": + sensors = ELECTRICITY_SENSORS + ADDITIONAL_SENSORS + elif meter.measurement_type == "GAS": + sensors = GAS_SENSORS + ADDITIONAL_SENSORS - for description in ADDITIONAL_SENSORS: - entities.append( - DiscovergySensor(description.key, description, meter, coordinator) - ) + entities.extend( + DiscovergySensor(value_key, description, meter, coordinator) + for description in sensors + for value_key in {description.key, *description.alternative_keys} + if description.value_fn(coordinator.data, value_key, description.scale) + ) - async_add_entities(entities, False) + async_add_entities(entities) class DiscovergySensor(CoordinatorEntity[DiscovergyUpdateCoordinator], SensorEntity): - """Represents a discovergy smart meter sensor.""" + """Represents a Discovergy smart meter sensor.""" entity_description: DiscovergySensorEntityDescription data_key: str diff --git a/homeassistant/components/dlink/strings.json b/homeassistant/components/dlink/strings.json index 8c60d59fa6b..9f21a9571e9 100644 --- a/homeassistant/components/dlink/strings.json +++ b/homeassistant/components/dlink/strings.json @@ -9,6 +9,7 @@ "use_legacy_protocol": "Use legacy protocol" }, "data_description": { + "host": "The hostname or IP address of your D-Link device", "password": "Default: PIN code on the back." } }, diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 3a57ba2c8ce..cd2f1ae2f50 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -453,10 +453,9 @@ class DlnaDmrEntity(MediaPlayerEntity): for state_variable in state_variables: # Force a state refresh when player begins or pauses playback # to update the position info. - if ( - state_variable.name == "TransportState" - and state_variable.value - in (TransportState.PLAYING, TransportState.PAUSED_PLAYBACK) + if state_variable.name == "TransportState" and state_variable.value in ( + TransportState.PLAYING, + TransportState.PAUSED_PLAYBACK, ): force_refresh = True diff --git a/homeassistant/components/doorbird/strings.json b/homeassistant/components/doorbird/strings.json index ceaf1a891ee..c851de379d4 100644 --- a/homeassistant/components/doorbird/strings.json +++ b/homeassistant/components/doorbird/strings.json @@ -17,8 +17,11 @@ "data": { "password": "[%key:common::config_flow::data::password%]", "host": "[%key:common::config_flow::data::host%]", - "name": "Device Name", + "name": "Device name", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The hostname or IP address of your DoorBird device." } } }, diff --git a/homeassistant/components/dremel_3d_printer/strings.json b/homeassistant/components/dremel_3d_printer/strings.json index 0016b8f2bca..9f6870b57f6 100644 --- a/homeassistant/components/dremel_3d_printer/strings.json +++ b/homeassistant/components/dremel_3d_printer/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Dremel 3D printer." } } }, diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index 5e1a54aedc4..45332546195 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -12,7 +12,6 @@ LOGGER = logging.getLogger(__package__) PLATFORMS = [Platform.SENSOR] CONF_DSMR_VERSION = "dsmr_version" CONF_PROTOCOL = "protocol" -CONF_RECONNECT_INTERVAL = "reconnect_interval" CONF_PRECISION = "precision" CONF_TIME_BETWEEN_UPDATE = "time_between_update" @@ -29,6 +28,7 @@ DATA_TASK = "task" DEVICE_NAME_ELECTRICITY = "Electricity Meter" DEVICE_NAME_GAS = "Gas Meter" +DEVICE_NAME_WATER = "Water Meter" DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S", "Q3D"} diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index fa58bd8c5a6..b128f9d3baa 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -34,6 +34,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import CoreState, Event, HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -47,7 +48,6 @@ from .const import ( CONF_DSMR_VERSION, CONF_PRECISION, CONF_PROTOCOL, - CONF_RECONNECT_INTERVAL, CONF_SERIAL_ID, CONF_SERIAL_ID_GAS, CONF_TIME_BETWEEN_UPDATE, @@ -57,6 +57,7 @@ from .const import ( DEFAULT_TIME_BETWEEN_UPDATE, DEVICE_NAME_ELECTRICITY, DEVICE_NAME_GAS, + DEVICE_NAME_WATER, DOMAIN, DSMR_PROTOCOL, LOGGER, @@ -67,21 +68,14 @@ EVENT_FIRST_TELEGRAM = "dsmr_first_telegram_{}" UNIT_CONVERSION = {"m3": UnitOfVolume.CUBIC_METERS} -@dataclass -class DSMRSensorEntityDescriptionMixin: - """Mixin for required keys.""" - - obis_reference: str - - -@dataclass -class DSMRSensorEntityDescription( - SensorEntityDescription, DSMRSensorEntityDescriptionMixin -): +@dataclass(kw_only=True) +class DSMRSensorEntityDescription(SensorEntityDescription): """Represents an DSMR Sensor.""" dsmr_versions: set[str] | None = None is_gas: bool = False + is_water: bool = False + obis_reference: str SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( @@ -90,7 +84,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( translation_key="current_electricity_usage", obis_reference=obis_references.CURRENT_ELECTRICITY_USAGE, device_class=SensorDeviceClass.POWER, - force_update=True, state_class=SensorStateClass.MEASUREMENT, ), DSMRSensorEntityDescription( @@ -98,7 +91,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( translation_key="current_electricity_delivery", obis_reference=obis_references.CURRENT_ELECTRICITY_DELIVERY, device_class=SensorDeviceClass.POWER, - force_update=True, state_class=SensorStateClass.MEASUREMENT, ), DSMRSensorEntityDescription( @@ -116,7 +108,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference=obis_references.ELECTRICITY_USED_TARIFF_1, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, device_class=SensorDeviceClass.ENERGY, - force_update=True, state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRSensorEntityDescription( @@ -124,7 +115,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( translation_key="electricity_used_tariff_2", obis_reference=obis_references.ELECTRICITY_USED_TARIFF_2, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, - force_update=True, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -133,7 +123,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( translation_key="electricity_delivered_tariff_1", obis_reference=obis_references.ELECTRICITY_DELIVERED_TARIFF_1, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, - force_update=True, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -142,7 +131,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( translation_key="electricity_delivered_tariff_2", obis_reference=obis_references.ELECTRICITY_DELIVERED_TARIFF_2, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, - force_update=True, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -342,7 +330,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( translation_key="electricity_imported_total", obis_reference=obis_references.ELECTRICITY_IMPORTED_TOTAL, dsmr_versions={"5L", "5S", "Q3D"}, - force_update=True, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -351,7 +338,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( translation_key="electricity_exported_total", obis_reference=obis_references.ELECTRICITY_EXPORTED_TOTAL, dsmr_versions={"5L", "5S", "Q3D"}, - force_update=True, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -360,7 +346,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( translation_key="current_average_demand", obis_reference=obis_references.BELGIUM_CURRENT_AVERAGE_DEMAND, dsmr_versions={"5B"}, - force_update=True, device_class=SensorDeviceClass.POWER, ), DSMRSensorEntityDescription( @@ -368,7 +353,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( translation_key="maximum_demand_current_month", obis_reference=obis_references.BELGIUM_MAXIMUM_DEMAND_MONTH, dsmr_versions={"5B"}, - force_update=True, device_class=SensorDeviceClass.POWER, ), DSMRSensorEntityDescription( @@ -377,7 +361,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference=obis_references.HOURLY_GAS_METER_READING, dsmr_versions={"4", "5", "5L"}, is_gas=True, - force_update=True, device_class=SensorDeviceClass.GAS, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -387,36 +370,144 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference=obis_references.GAS_METER_READING, dsmr_versions={"2.2"}, is_gas=True, - force_update=True, device_class=SensorDeviceClass.GAS, state_class=SensorStateClass.TOTAL_INCREASING, ), ) -def add_gas_sensor_5B(telegram: dict[str, DSMRObject]) -> DSMRSensorEntityDescription: - """Return correct entity for 5B Gas meter.""" - ref = None - if obis_references.BELGIUM_MBUS1_METER_READING2 in telegram: - ref = obis_references.BELGIUM_MBUS1_METER_READING2 - elif obis_references.BELGIUM_MBUS2_METER_READING2 in telegram: - ref = obis_references.BELGIUM_MBUS2_METER_READING2 - elif obis_references.BELGIUM_MBUS3_METER_READING2 in telegram: - ref = obis_references.BELGIUM_MBUS3_METER_READING2 - elif obis_references.BELGIUM_MBUS4_METER_READING2 in telegram: - ref = obis_references.BELGIUM_MBUS4_METER_READING2 - elif ref is None: - ref = obis_references.BELGIUM_MBUS1_METER_READING2 - return DSMRSensorEntityDescription( - key="belgium_5min_gas_meter_reading", - translation_key="gas_meter_reading", - obis_reference=ref, - dsmr_versions={"5B"}, - is_gas=True, - force_update=True, - device_class=SensorDeviceClass.GAS, - state_class=SensorStateClass.TOTAL_INCREASING, - ) +def create_mbus_entity( + mbus: int, mtype: int, telegram: dict[str, DSMRObject] +) -> DSMRSensorEntityDescription | None: + """Create a new MBUS Entity.""" + if ( + mtype == 3 + and ( + obis_reference := getattr( + obis_references, f"BELGIUM_MBUS{mbus}_METER_READING2" + ) + ) + in telegram + ): + return DSMRSensorEntityDescription( + key=f"mbus{mbus}_gas_reading", + translation_key="gas_meter_reading", + obis_reference=obis_reference, + is_gas=True, + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, + ) + if ( + mtype == 7 + and ( + obis_reference := getattr( + obis_references, f"BELGIUM_MBUS{mbus}_METER_READING1" + ) + ) + in telegram + ): + return DSMRSensorEntityDescription( + key=f"mbus{mbus}_water_reading", + translation_key="water_meter_reading", + obis_reference=obis_reference, + is_water=True, + device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + ) + return None + + +def device_class_and_uom( + telegram: dict[str, DSMRObject], + entity_description: DSMRSensorEntityDescription, +) -> tuple[SensorDeviceClass | None, str | None]: + """Get native unit of measurement from telegram,.""" + dsmr_object = telegram[entity_description.obis_reference] + uom: str | None = getattr(dsmr_object, "unit") or None + with suppress(ValueError): + if entity_description.device_class == SensorDeviceClass.GAS and ( + enery_uom := UnitOfEnergy(str(uom)) + ): + return (SensorDeviceClass.ENERGY, enery_uom) + if uom in UNIT_CONVERSION: + return (entity_description.device_class, UNIT_CONVERSION[uom]) + return (entity_description.device_class, uom) + + +def rename_old_gas_to_mbus( + hass: HomeAssistant, entry: ConfigEntry, mbus_device_id: str +) -> None: + """Rename old gas sensor to mbus variant.""" + dev_reg = dr.async_get(hass) + device_entry_v1 = dev_reg.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) + if device_entry_v1 is not None: + device_id = device_entry_v1.id + + ent_reg = er.async_get(hass) + entries = er.async_entries_for_device(ent_reg, device_id) + + for entity in entries: + if entity.unique_id.endswith("belgium_5min_gas_meter_reading"): + try: + ent_reg.async_update_entity( + entity.entity_id, + new_unique_id=mbus_device_id, + device_id=mbus_device_id, + ) + except ValueError: + LOGGER.debug( + "Skip migration of %s because it already exists", + entity.entity_id, + ) + else: + LOGGER.debug( + "Migrated entity %s from unique id %s to %s", + entity.entity_id, + entity.unique_id, + mbus_device_id, + ) + # Cleanup old device + dev_entities = er.async_entries_for_device( + ent_reg, device_id, include_disabled_entities=True + ) + if not dev_entities: + dev_reg.async_remove_device(device_id) + + +def create_mbus_entities( + hass: HomeAssistant, telegram: dict[str, DSMRObject], entry: ConfigEntry +) -> list[DSMREntity]: + """Create MBUS Entities.""" + entities = [] + for idx in range(1, 5): + if ( + device_type := getattr(obis_references, f"BELGIUM_MBUS{idx}_DEVICE_TYPE") + ) not in telegram: + continue + if (type_ := int(telegram[device_type].value)) not in (3, 7): + continue + if ( + identifier := getattr( + obis_references, + f"BELGIUM_MBUS{idx}_EQUIPMENT_IDENTIFIER", + ) + ) in telegram: + serial_ = telegram[identifier].value + rename_old_gas_to_mbus(hass, entry, serial_) + else: + serial_ = "" + if description := create_mbus_entity(idx, type_, telegram): + entities.append( + DSMREntity( + description, + entry, + telegram, + *device_class_and_uom(telegram, description), # type: ignore[arg-type] + serial_, + idx, + ) + ) + return entities async def async_setup_entry( @@ -436,25 +527,10 @@ async def async_setup_entry( add_entities_handler() add_entities_handler = None - def device_class_and_uom( - telegram: dict[str, DSMRObject], - entity_description: DSMRSensorEntityDescription, - ) -> tuple[SensorDeviceClass | None, str | None]: - """Get native unit of measurement from telegram,.""" - dsmr_object = telegram[entity_description.obis_reference] - uom: str | None = getattr(dsmr_object, "unit") or None - with suppress(ValueError): - if entity_description.device_class == SensorDeviceClass.GAS and ( - enery_uom := UnitOfEnergy(str(uom)) - ): - return (SensorDeviceClass.ENERGY, enery_uom) - if uom in UNIT_CONVERSION: - return (entity_description.device_class, UNIT_CONVERSION[uom]) - return (entity_description.device_class, uom) - - all_sensors = SENSORS if dsmr_version == "5B": - all_sensors += (add_gas_sensor_5B(telegram),) + mbus_entities = create_mbus_entities(hass, telegram, entry) + for mbus_entity in mbus_entities: + entities.append(mbus_entity) entities.extend( [ @@ -462,11 +538,9 @@ async def async_setup_entry( description, entry, telegram, - *device_class_and_uom( - telegram, description - ), # type: ignore[arg-type] + *device_class_and_uom(telegram, description), # type: ignore[arg-type] ) - for description in all_sensors + for description in SENSORS if ( description.dsmr_versions is None or dsmr_version in description.dsmr_versions @@ -572,9 +646,7 @@ async def async_setup_entry( update_entities_telegram(None) # throttle reconnect attempts - await asyncio.sleep( - entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL) - ) + await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL) except (serial.serialutil.SerialException, OSError): # Log any error while establishing connection and drop to retry @@ -588,9 +660,7 @@ async def async_setup_entry( update_entities_telegram(None) # throttle reconnect attempts - await asyncio.sleep( - entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL) - ) + await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL) except CancelledError: # Reflect disconnect state in devices state by setting an # None telegram resulting in `unavailable` states @@ -641,6 +711,8 @@ class DSMREntity(SensorEntity): telegram: dict[str, DSMRObject], device_class: SensorDeviceClass, native_unit_of_measurement: str | None, + serial_id: str = "", + mbus_id: int = 0, ) -> None: """Initialize entity.""" self.entity_description = entity_description @@ -652,8 +724,15 @@ class DSMREntity(SensorEntity): device_serial = entry.data[CONF_SERIAL_ID] device_name = DEVICE_NAME_ELECTRICITY if entity_description.is_gas: - device_serial = entry.data[CONF_SERIAL_ID_GAS] + if serial_id: + device_serial = serial_id + else: + device_serial = entry.data[CONF_SERIAL_ID_GAS] device_name = DEVICE_NAME_GAS + if entity_description.is_water: + if serial_id: + device_serial = serial_id + device_name = DEVICE_NAME_WATER if device_serial is None: device_serial = entry.entry_id @@ -661,7 +740,13 @@ class DSMREntity(SensorEntity): identifiers={(DOMAIN, device_serial)}, name=device_name, ) - self._attr_unique_id = f"{device_serial}_{entity_description.key}" + if mbus_id != 0: + if serial_id: + self._attr_unique_id = f"{device_serial}" + else: + self._attr_unique_id = f"{device_serial}_{mbus_id}" + else: + self._attr_unique_id = f"{device_serial}_{entity_description.key}" @callback def update_data(self, telegram: dict[str, DSMRObject] | None) -> None: @@ -709,6 +794,10 @@ class DSMREntity(SensorEntity): float(value), self._entry.data.get(CONF_PRECISION, DEFAULT_PRECISION) ) + # Make sure we do not return a zero value for an energy sensor + if not value and self.state_class == SensorStateClass.TOTAL_INCREASING: + return None + return value @staticmethod diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json index 5f0568e2905..055c0c41264 100644 --- a/homeassistant/components/dsmr/strings.json +++ b/homeassistant/components/dsmr/strings.json @@ -147,6 +147,9 @@ }, "voltage_swell_l3_count": { "name": "Voltage swells phase L3" + }, + "water_meter_reading": { + "name": "Water consumption" } } }, diff --git a/homeassistant/components/dunehd/strings.json b/homeassistant/components/dunehd/strings.json index f7e12b39f16..7d60a720a98 100644 --- a/homeassistant/components/dunehd/strings.json +++ b/homeassistant/components/dunehd/strings.json @@ -5,6 +5,9 @@ "description": "Ensure that your player is turned on.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Dune HD device." } } }, diff --git a/homeassistant/components/duotecno/climate.py b/homeassistant/components/duotecno/climate.py index e7dfa53e53c..8e23e742c04 100644 --- a/homeassistant/components/duotecno/climate.py +++ b/homeassistant/components/duotecno/climate.py @@ -23,12 +23,7 @@ HVACMODE: Final = { } HVACMODE_REVERSE: Final = {value: key for key, value in HVACMODE.items()} -PRESETMODES: Final = { - "sun": 0, - "half_sun": 1, - "moon": 2, - "half_moon": 3, -} +PRESETMODES: Final = {"sun": 0, "half_sun": 1, "moon": 2, "half_moon": 3} PRESETMODES_REVERSE: Final = {value: key for key, value in PRESETMODES.items()} @@ -88,5 +83,10 @@ class DuotecnoClimate(DuotecnoEntity, ClimateEntity): """Set the preset mode.""" await self._unit.set_preset(PRESETMODES[preset_mode]) + @api_call async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Duotecno does not support setting this, we can only display it.""" + if hvac_mode == HVACMode.OFF: + await self._unit.turn_off() + else: + await self._unit.turn_on() diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json index f6482791292..2f221929178 100644 --- a/homeassistant/components/duotecno/manifest.json +++ b/homeassistant/components/duotecno/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/duotecno", "iot_class": "local_push", + "loggers": ["pyduotecno", "pyduotecno-node", "pyduotecno-unit"], "quality_scale": "silver", - "requirements": ["pyDuotecno==2023.10.1"] + "requirements": ["pyDuotecno==2023.11.1"] } diff --git a/homeassistant/components/duotecno/strings.json b/homeassistant/components/duotecno/strings.json index 93a545d31dc..a5585c3dd2c 100644 --- a/homeassistant/components/duotecno/strings.json +++ b/homeassistant/components/duotecno/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Duotecno device." } } }, diff --git a/homeassistant/components/dwd_weather_warnings/config_flow.py b/homeassistant/components/dwd_weather_warnings/config_flow.py index e806db7ec91..1e0bb797c7a 100644 --- a/homeassistant/components/dwd_weather_warnings/config_flow.py +++ b/homeassistant/components/dwd_weather_warnings/config_flow.py @@ -8,11 +8,10 @@ from dwdwfsapi import DwdWeatherWarningsAPI import voluptuous as vol from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_NAME from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv -from .const import CONF_REGION_IDENTIFIER, CONF_REGION_NAME, DOMAIN, LOGGER +from .const import CONF_REGION_IDENTIFIER, DOMAIN class DwdWeatherWarningsConfigFlow(ConfigFlow, domain=DOMAIN): @@ -51,27 +50,3 @@ class DwdWeatherWarningsConfigFlow(ConfigFlow, domain=DOMAIN): } ), ) - - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - LOGGER.debug( - "Starting import of sensor from configuration.yaml - %s", import_config - ) - - # Extract the necessary data for the setup. - region_identifier = import_config[CONF_REGION_NAME] - name = import_config.get(CONF_NAME, region_identifier) - - # Set the unique ID for this imported entry. - await self.async_set_unique_id(region_identifier) - self._abort_if_unique_id_configured() - - # Validate region identifier using the API - if not await self.hass.async_add_executor_job( - DwdWeatherWarningsAPI, region_identifier - ): - return self.async_abort(reason="invalid_identifier") - - return self.async_create_entry( - title=name, data={CONF_REGION_IDENTIFIER: region_identifier} - ) diff --git a/homeassistant/components/dwd_weather_warnings/manifest.json b/homeassistant/components/dwd_weather_warnings/manifest.json index dab3a39c10f..1a497b64ae3 100644 --- a/homeassistant/components/dwd_weather_warnings/manifest.json +++ b/homeassistant/components/dwd_weather_warnings/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@runningman84", "@stephan192", "@andarotajo"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dwd_weather_warnings", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["dwdwfsapi"], "requirements": ["dwdwfsapi==1.0.6"] diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index 78154e9e4f4..e88fb3c408b 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -11,23 +11,11 @@ Wetterwarnungen (Stufe 1) from __future__ import annotations -from typing import Final - -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, - SensorEntity, - SensorEntityDescription, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -46,7 +34,6 @@ from .const import ( ATTR_REGION_ID, ATTR_REGION_NAME, ATTR_WARNING_COUNT, - CONF_REGION_NAME, CURRENT_WARNING_SENSOR, DEFAULT_NAME, DOMAIN, @@ -66,49 +53,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), ) -# Should be removed together with the old YAML configuration. -YAML_MONITORED_CONDITIONS: Final = [CURRENT_WARNING_SENSOR, ADVANCE_WARNING_SENSOR] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_REGION_NAME): cv.string, - vol.Optional(CONF_NAME): cv.string, - vol.Optional( - CONF_MONITORED_CONDITIONS, default=YAML_MONITORED_CONDITIONS - ): vol.All(cv.ensure_list, [vol.In(YAML_MONITORED_CONDITIONS)]), - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Import the configurations from YAML to config flows.""" - # Show issue as long as the YAML configuration exists. - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2023.12.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Deutscher Wetterdienst (DWD) Weather Warnings", - }, - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -146,7 +90,9 @@ class DwdWeatherWarningsSensor( self._attr_unique_id = f"{entry.unique_id}-{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, entry.entry_id)}, name=f"{DEFAULT_NAME} {entry.title}" + identifiers={(DOMAIN, entry.entry_id)}, + name=f"{DEFAULT_NAME} {entry.title}", + entry_type=DeviceEntryType.SERVICE, ) self.api = coordinator.api diff --git a/homeassistant/components/dwd_weather_warnings/strings.json b/homeassistant/components/dwd_weather_warnings/strings.json index dc73055174b..aa460dcc6d5 100644 --- a/homeassistant/components/dwd_weather_warnings/strings.json +++ b/homeassistant/components/dwd_weather_warnings/strings.json @@ -19,10 +19,38 @@ "entity": { "sensor": { "current_warning_level": { - "name": "Current warning level" + "name": "Current warning level", + "state_attributes": { + "region_name": { + "name": "Region name" + }, + "region_id": { + "name": "Region ID" + }, + "last_update": { + "name": "Last update" + }, + "warning_count": { + "name": "Warning count" + } + } }, "advance_warning_level": { - "name": "Advance warning level" + "name": "Advance warning level", + "state_attributes": { + "region_name": { + "name": "[%key:component::dwd_weather_warnings::entity::sensor::current_warning_level::state_attributes::region_name::name%]" + }, + "region_id": { + "name": "[%key:component::dwd_weather_warnings::entity::sensor::current_warning_level::state_attributes::region_id::name%]" + }, + "last_update": { + "name": "[%key:component::dwd_weather_warnings::entity::sensor::current_warning_level::state_attributes::last_update::name%]" + }, + "warning_count": { + "name": "[%key:component::dwd_weather_warnings::entity::sensor::current_warning_level::state_attributes::warning_count::name%]" + } + } } } } diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index 5755a1b3dbe..6fa177c7221 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["easyenergy==0.3.0"] + "requirements": ["easyenergy==1.0.0"] } diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index e1253b585ac..1b0e65f7390 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -99,6 +99,7 @@ ECOBEE_HVAC_ACTION_TO_HASS = { "economizer": HVACAction.FAN, "compHotWater": None, "auxHotWater": None, + "compWaterHeater": None, } PRESET_TO_ECOBEE_HOLD = { diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json index 71f5e04f75a..1160cd946d9 100644 --- a/homeassistant/components/ecobee/manifest.json +++ b/homeassistant/components/ecobee/manifest.json @@ -1,7 +1,7 @@ { "domain": "ecobee", "name": "ecobee", - "codeowners": ["@marthoc", "@marcolivierarsenault"], + "codeowners": ["@marcolivierarsenault"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecobee", "homekit": { @@ -9,7 +9,7 @@ }, "iot_class": "cloud_polling", "loggers": ["pyecobee"], - "requirements": ["python-ecobee-api==0.2.14"], + "requirements": ["python-ecobee-api==0.2.17"], "zeroconf": [ { "type": "_ecobee._tcp.local." diff --git a/homeassistant/components/ecoforest/manifest.json b/homeassistant/components/ecoforest/manifest.json index 99b63fade5f..cca44c5b2a9 100644 --- a/homeassistant/components/ecoforest/manifest.json +++ b/homeassistant/components/ecoforest/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecoforest", "iot_class": "local_polling", "loggers": ["pyecoforest"], - "requirements": ["pyecoforest==0.3.0"] + "requirements": ["pyecoforest==0.4.0"] } diff --git a/homeassistant/components/ecoforest/sensor.py b/homeassistant/components/ecoforest/sensor.py index 91f3138af37..e595ddb65f7 100644 --- a/homeassistant/components/ecoforest/sensor.py +++ b/homeassistant/components/ecoforest/sensor.py @@ -13,7 +13,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfTemperature +from homeassistant.const import ( + PERCENTAGE, + UnitOfPressure, + UnitOfTemperature, + UnitOfTime, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -88,6 +93,59 @@ SENSOR_TYPES: tuple[EcoforestSensorEntityDescription, ...] = ( icon="mdi:alert", value_fn=lambda data: data.alarm.value if data.alarm else "none", ), + EcoforestSensorEntityDescription( + key="depression", + translation_key="depression", + native_unit_of_measurement=UnitOfPressure.PA, + device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, + entity_registry_enabled_default=False, + value_fn=lambda data: data.depression, + ), + EcoforestSensorEntityDescription( + key="working_hours", + translation_key="working_hours", + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + entity_registry_enabled_default=False, + value_fn=lambda data: data.working_hours, + ), + EcoforestSensorEntityDescription( + key="ignitions", + translation_key="ignitions", + native_unit_of_measurement="ignitions", + entity_registry_enabled_default=False, + value_fn=lambda data: data.ignitions, + ), + EcoforestSensorEntityDescription( + key="live_pulse", + translation_key="live_pulse", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + entity_registry_enabled_default=False, + value_fn=lambda data: data.live_pulse, + ), + EcoforestSensorEntityDescription( + key="pulse_offset", + translation_key="pulse_offset", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + entity_registry_enabled_default=False, + value_fn=lambda data: data.pulse_offset, + ), + EcoforestSensorEntityDescription( + key="extractor", + translation_key="extractor", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + value_fn=lambda data: data.extractor, + ), + EcoforestSensorEntityDescription( + key="convecto_air_flow", + translation_key="convecto_air_flow", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + value_fn=lambda data: data.convecto_air_flow, + ), ) diff --git a/homeassistant/components/ecoforest/strings.json b/homeassistant/components/ecoforest/strings.json index bd0605eab82..1094e10ada3 100644 --- a/homeassistant/components/ecoforest/strings.json +++ b/homeassistant/components/ecoforest/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Ecoforest device." } } }, @@ -50,6 +53,33 @@ "unkownn": "Unknown alarm", "none": "None" } + }, + "depression": { + "name": "Depression" + }, + "working_hours": { + "name": "Working time" + }, + "working_state": { + "name": "Working state" + }, + "working_level": { + "name": "Working level" + }, + "ignitions": { + "name": "Ignitions" + }, + "live_pulse": { + "name": "Live pulse" + }, + "pulse_offset": { + "name": "Pulse offset" + }, + "extractor": { + "name": "Extractor" + }, + "convecto_air_flow": { + "name": "Convecto air flow" } }, "number": { diff --git a/homeassistant/components/econet/__init__.py b/homeassistant/components/econet/__init__.py index 36cdeb68821..67cbd7496e3 100644 --- a/homeassistant/components/econet/__init__.py +++ b/homeassistant/components/econet/__init__.py @@ -1,4 +1,5 @@ """Support for EcoNet products.""" +import asyncio from datetime import timedelta import logging @@ -80,14 +81,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await hass.async_add_executor_job(api.unsubscribe) api.subscribe() - async def fetch_update(now): - """Fetch the latest changes from the API.""" + # Refresh values + await asyncio.sleep(60) await api.refresh_equipment() config_entry.async_on_unload(async_track_time_interval(hass, resubscribe, INTERVAL)) - config_entry.async_on_unload( - async_track_time_interval(hass, fetch_update, INTERVAL + timedelta(minutes=1)) - ) return True diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index e77c4face74..f5328da4776 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -64,6 +64,7 @@ async def async_setup_entry( class EcoNetThermostat(EcoNetEntity, ClimateEntity): """Define an Econet thermostat.""" + _attr_should_poll = True _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT def __init__(self, thermostat): diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json index 26b04929a45..c96867b489b 100644 --- a/homeassistant/components/econet/manifest.json +++ b/homeassistant/components/econet/manifest.json @@ -1,7 +1,7 @@ { "domain": "econet", "name": "Rheem EcoNet Products", - "codeowners": ["@vangorra", "@w1ll1am23"], + "codeowners": ["@w1ll1am23"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/econet", "iot_class": "cloud_push", diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index cbaf4551d03..a99ab087729 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -1,4 +1,5 @@ """Support for Rheem EcoNet water heaters.""" +from datetime import timedelta import logging from typing import Any @@ -17,12 +18,14 @@ from homeassistant.components.water_heater import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import EcoNetEntity from .const import DOMAIN, EQUIPMENT +SCAN_INTERVAL = timedelta(hours=1) + _LOGGER = logging.getLogger(__name__) ECONET_STATE_TO_HA = { @@ -52,6 +55,7 @@ async def async_setup_entry( EcoNetWaterHeater(water_heater) for water_heater in equipment[EquipmentType.WATER_HEATER] ], + update_before_add=True, ) @@ -64,18 +68,8 @@ class EcoNetWaterHeater(EcoNetEntity, WaterHeaterEntity): def __init__(self, water_heater): """Initialize.""" super().__init__(water_heater) - self._running = water_heater.running self.water_heater = water_heater - @callback - def on_update_received(self): - """Update was pushed from the econet API.""" - if self._running != self.water_heater.running: - # Water heater running state has changed so check usage on next update - self._attr_should_poll = True - self._running = self.water_heater.running - self.async_write_ha_state() - @property def is_away_mode_on(self): """Return true if away mode is on.""" @@ -153,8 +147,6 @@ class EcoNetWaterHeater(EcoNetEntity, WaterHeaterEntity): """Get the latest energy usage.""" await self.water_heater.get_energy_usage() await self.water_heater.get_water_usage() - self.async_write_ha_state() - self._attr_should_poll = False def turn_away_mode_on(self) -> None: """Turn away mode on.""" diff --git a/homeassistant/components/electric_kiwi/strings.json b/homeassistant/components/electric_kiwi/strings.json index 81de5cef896..d21c0d80ca6 100644 --- a/homeassistant/components/electric_kiwi/strings.json +++ b/homeassistant/components/electric_kiwi/strings.json @@ -17,7 +17,10 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" @@ -25,17 +28,9 @@ }, "entity": { "sensor": { - "hopfreepowerstart": { - "name": "Hour of free power start" - }, - "hopfreepowerend": { - "name": "Hour of free power end" - } + "hopfreepowerstart": { "name": "Hour of free power start" }, + "hopfreepowerend": { "name": "Hour of free power end" } }, - "select": { - "hopselector": { - "name": "Hour of free power" - } - } + "select": { "hopselector": { "name": "Hour of free power" } } } } diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py index b05cd532c16..7a69db24012 100644 --- a/homeassistant/components/elgato/button.py +++ b/homeassistant/components/elgato/button.py @@ -23,20 +23,13 @@ from .coordinator import ElgatoDataUpdateCoordinator from .entity import ElgatoEntity -@dataclass -class ElgatoButtonEntityDescriptionMixin: - """Mixin values for Elgato entities.""" +@dataclass(kw_only=True) +class ElgatoButtonEntityDescription(ButtonEntityDescription): + """Class describing Elgato button entities.""" press_fn: Callable[[Elgato], Awaitable[Any]] -@dataclass -class ElgatoButtonEntityDescription( - ButtonEntityDescription, ElgatoButtonEntityDescriptionMixin -): - """Class describing Elgato button entities.""" - - BUTTONS = [ ElgatoButtonEntityDescription( key="identify", diff --git a/homeassistant/components/elgato/diagnostics.py b/homeassistant/components/elgato/diagnostics.py index c63290f736f..46730b8f005 100644 --- a/homeassistant/components/elgato/diagnostics.py +++ b/homeassistant/components/elgato/diagnostics.py @@ -16,6 +16,6 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" coordinator: ElgatoDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] return { - "info": coordinator.data.info.dict(), - "state": coordinator.data.state.dict(), + "info": coordinator.data.info.to_dict(), + "state": coordinator.data.state.to_dict(), } diff --git a/homeassistant/components/elgato/entity.py b/homeassistant/components/elgato/entity.py index 1bbd32f5b44..3f46b51d7b7 100644 --- a/homeassistant/components/elgato/entity.py +++ b/homeassistant/components/elgato/entity.py @@ -1,7 +1,7 @@ """Base entity for the Elgato integration.""" from __future__ import annotations -from homeassistant.const import CONF_MAC +from homeassistant.const import ATTR_CONNECTIONS, CONF_MAC from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, DeviceInfo, @@ -31,6 +31,6 @@ class ElgatoEntity(CoordinatorEntity[ElgatoDataUpdateCoordinator]): hw_version=str(coordinator.data.info.hardware_board_type), ) if (mac := coordinator.config_entry.data.get(CONF_MAC)) is not None: - self._attr_device_info["connections"] = { + self._attr_device_info[ATTR_CONNECTIONS] = { (CONNECTION_NETWORK_MAC, format_mac(mac)) } diff --git a/homeassistant/components/elgato/manifest.json b/homeassistant/components/elgato/manifest.json index 49340f028d0..0671a7adb1d 100644 --- a/homeassistant/components/elgato/manifest.json +++ b/homeassistant/components/elgato/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["elgato==5.0.0"], + "requirements": ["elgato==5.1.1"], "zeroconf": ["_elg._tcp.local."] } diff --git a/homeassistant/components/elgato/sensor.py b/homeassistant/components/elgato/sensor.py index 8ed8265705c..27dedee25c9 100644 --- a/homeassistant/components/elgato/sensor.py +++ b/homeassistant/components/elgato/sensor.py @@ -26,20 +26,12 @@ from .coordinator import ElgatoData, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity -@dataclass -class ElgatoEntityDescriptionMixin: - """Mixin values for Elgato entities.""" - - value_fn: Callable[[ElgatoData], float | int | None] - - -@dataclass -class ElgatoSensorEntityDescription( - SensorEntityDescription, ElgatoEntityDescriptionMixin -): +@dataclass(kw_only=True) +class ElgatoSensorEntityDescription(SensorEntityDescription): """Class describing Elgato sensor entities.""" has_fn: Callable[[ElgatoData], bool] = lambda _: True + value_fn: Callable[[ElgatoData], float | int | None] SENSORS = [ diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index e6b16215793..6e1031c8ddf 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Elgato device." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/elgato/switch.py b/homeassistant/components/elgato/switch.py index 78af3adfa53..e9ab506c3a4 100644 --- a/homeassistant/components/elgato/switch.py +++ b/homeassistant/components/elgato/switch.py @@ -19,21 +19,13 @@ from .coordinator import ElgatoData, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity -@dataclass -class ElgatoEntityDescriptionMixin: - """Mixin values for Elgato entities.""" - - is_on_fn: Callable[[ElgatoData], bool | None] - set_fn: Callable[[Elgato, bool], Awaitable[Any]] - - -@dataclass -class ElgatoSwitchEntityDescription( - SwitchEntityDescription, ElgatoEntityDescriptionMixin -): +@dataclass(kw_only=True) +class ElgatoSwitchEntityDescription(SwitchEntityDescription): """Class describing Elgato switch entities.""" has_fn: Callable[[ElgatoData], bool] = lambda _: True + is_on_fn: Callable[[ElgatoData], bool | None] + set_fn: Callable[[Elgato, bool], Awaitable[Any]] SWITCHES = [ diff --git a/homeassistant/components/elkm1/discovery.py b/homeassistant/components/elkm1/discovery.py index 50db2840753..83b2d3f113b 100644 --- a/homeassistant/components/elkm1/discovery.py +++ b/homeassistant/components/elkm1/discovery.py @@ -63,6 +63,8 @@ async def async_discover_devices( if isinstance(discovered, Exception): _LOGGER.debug("Scanning %s failed with error: %s", targets[idx], discovered) continue + if isinstance(discovered, BaseException): + raise discovered from None for device in discovered: assert isinstance(device, ElkSystem) combined_discoveries[device.ip_address] = device diff --git a/homeassistant/components/elmax/cover.py b/homeassistant/components/elmax/cover.py index 8a6acb154aa..e05b17b9171 100644 --- a/homeassistant/components/elmax/cover.py +++ b/homeassistant/components/elmax/cover.py @@ -18,13 +18,11 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -_COMMAND_BY_MOTION_STATUS = ( - { # Maps the stop command to use for every cover motion status - CoverStatus.DOWN: CoverCommand.DOWN, - CoverStatus.UP: CoverCommand.UP, - CoverStatus.IDLE: None, - } -) +_COMMAND_BY_MOTION_STATUS = { # Maps the stop command to use for every cover motion status + CoverStatus.DOWN: CoverCommand.DOWN, + CoverStatus.UP: CoverCommand.UP, + CoverStatus.IDLE: None, +} async def async_setup_entry( diff --git a/homeassistant/components/emonitor/strings.json b/homeassistant/components/emonitor/strings.json index 675db107935..08ffe030890 100644 --- a/homeassistant/components/emonitor/strings.json +++ b/homeassistant/components/emonitor/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your SiteSage Emonitor device." } }, "confirm": { diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index a98d2c08a48..1ba93da716c 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -6,7 +6,6 @@ import logging from aiohttp import web import voluptuous as vol -from homeassistant.components.http import HomeAssistantAccessLogger from homeassistant.components.network import async_get_source_ip from homeassistant.const import ( CONF_ENTITIES, @@ -101,7 +100,7 @@ async def start_emulated_hue_bridge( config.advertise_port or config.listen_port, ) - runner = web.AppRunner(app, access_log_class=HomeAssistantAccessLogger) + runner = web.AppRunner(app) await runner.setup() site = web.TCPSite(runner, config.host_ip_addr, config.listen_port) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 4dbe5aa315e..ad6b0541cd6 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -772,7 +772,9 @@ def state_to_json(config: Config, state: State) -> dict[str, Any]: "swversion": "123", } - if light.color_supported(color_modes) and light.color_temp_supported(color_modes): + color_supported = light.color_supported(color_modes) + color_temp_supported = light.color_temp_supported(color_modes) + if color_supported and color_temp_supported: # Extended Color light (Zigbee Device ID: 0x0210) # Same as Color light, but which supports additional setting of color temperature retval["type"] = "Extended color light" @@ -790,7 +792,7 @@ def state_to_json(config: Config, state: State) -> dict[str, Any]: json_state[HUE_API_STATE_COLORMODE] = "hs" else: json_state[HUE_API_STATE_COLORMODE] = "ct" - elif light.color_supported(color_modes): + elif color_supported: # Color light (Zigbee Device ID: 0x0200) # Supports on/off, dimming and color control (hue/saturation, enhanced hue, color loop and XY) retval["type"] = "Color light" @@ -804,7 +806,7 @@ def state_to_json(config: Config, state: State) -> dict[str, Any]: HUE_API_STATE_EFFECT: "none", } ) - elif light.color_temp_supported(color_modes): + elif color_temp_supported: # Color temperature light (Zigbee Device ID: 0x0220) # Supports groups, scenes, on/off, dimming, and setting of a color temperature retval["type"] = "Color temperature light" diff --git a/homeassistant/components/energyzero/manifest.json b/homeassistant/components/energyzero/manifest.json index 8e2b8aba894..9ef99173ffb 100644 --- a/homeassistant/components/energyzero/manifest.json +++ b/homeassistant/components/energyzero/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/energyzero", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["energyzero==0.5.0"] + "requirements": ["energyzero==1.0.0"] } diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index c8da6f74a40..c49e1f143e6 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -1,7 +1,7 @@ { "domain": "enphase_envoy", "name": "Enphase Envoy", - "codeowners": ["@bdraco", "@cgarwood", "@dgomes", "@joostlek"], + "codeowners": ["@bdraco", "@cgarwood", "@dgomes", "@joostlek", "@catsmanac"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 94cf9233745..fe32002e6b2 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -8,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Enphase Envoy gateway." } } }, diff --git a/homeassistant/components/envisalink/manifest.json b/homeassistant/components/envisalink/manifest.json index c048687c906..093ebf77eba 100644 --- a/homeassistant/components/envisalink/manifest.json +++ b/homeassistant/components/envisalink/manifest.json @@ -1,7 +1,7 @@ { "domain": "envisalink", "name": "Envisalink", - "codeowners": ["@ufodone"], + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/envisalink", "iot_class": "local_push", "loggers": ["pyenvisalink"], diff --git a/homeassistant/components/epson/media_player.py b/homeassistant/components/epson/media_player.py index 1f80be9fe06..1f401ed0a7d 100644 --- a/homeassistant/components/epson/media_player.py +++ b/homeassistant/components/epson/media_player.py @@ -37,7 +37,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import ( + DeviceInfo, + async_get as async_get_device_registry, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry @@ -55,8 +58,7 @@ async def async_setup_entry( projector: Projector = hass.data[DOMAIN][config_entry.entry_id] projector_entity = EpsonProjectorMediaPlayer( projector=projector, - name=config_entry.title, - unique_id=config_entry.unique_id, + unique_id=config_entry.unique_id or config_entry.entry_id, entry=config_entry, ) async_add_entities([projector_entity], True) @@ -71,6 +73,9 @@ async def async_setup_entry( class EpsonProjectorMediaPlayer(MediaPlayerEntity): """Representation of Epson Projector Device.""" + _attr_has_entity_name = True + _attr_name = None + _attr_supported_features = ( MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF @@ -82,38 +87,38 @@ class EpsonProjectorMediaPlayer(MediaPlayerEntity): ) def __init__( - self, projector: Projector, name: str, unique_id: str | None, entry: ConfigEntry + self, projector: Projector, unique_id: str, entry: ConfigEntry ) -> None: """Initialize entity to control Epson projector.""" self._projector = projector self._entry = entry - self._attr_name = name self._attr_available = False self._cmode = None self._attr_source_list = list(DEFAULT_SOURCES.values()) self._attr_unique_id = unique_id - if unique_id: - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, - manufacturer="Epson", - model="Epson", - name="Epson projector", - via_device=(DOMAIN, unique_id), - ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="Epson", + model="Epson", + ) async def set_unique_id(self) -> bool: """Set unique id for projector config entry.""" _LOGGER.debug("Setting unique_id for projector") - if self.unique_id: + if self._entry.unique_id: return False if uid := await self._projector.get_serial_number(): self.hass.config_entries.async_update_entry(self._entry, unique_id=uid) - registry = async_get_entity_registry(self.hass) - old_entity_id = registry.async_get_entity_id( + ent_reg = async_get_entity_registry(self.hass) + old_entity_id = ent_reg.async_get_entity_id( "media_player", DOMAIN, self._entry.entry_id ) if old_entity_id is not None: - registry.async_update_entity(old_entity_id, new_unique_id=uid) + ent_reg.async_update_entity(old_entity_id, new_unique_id=uid) + dev_reg = async_get_device_registry(self.hass) + device = dev_reg.async_get_device({(DOMAIN, self._entry.entry_id)}) + if device is not None: + dev_reg.async_update_device(device.id, new_identifiers={(DOMAIN, uid)}) self.hass.async_create_task( self.hass.config_entries.async_reload(self._entry.entry_id) ) diff --git a/homeassistant/components/epson/strings.json b/homeassistant/components/epson/strings.json index 4e3780322e9..94544c32d1d 100644 --- a/homeassistant/components/epson/strings.json +++ b/homeassistant/components/epson/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "The hostname or IP address of your Epson projector." } } }, diff --git a/homeassistant/components/eq3btsmart/__init__.py b/homeassistant/components/eq3btsmart/__init__.py deleted file mode 100644 index f32eba6944f..00000000000 --- a/homeassistant/components/eq3btsmart/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The eq3btsmart component.""" diff --git a/homeassistant/components/eq3btsmart/climate.py b/homeassistant/components/eq3btsmart/climate.py deleted file mode 100644 index 700bc61293f..00000000000 --- a/homeassistant/components/eq3btsmart/climate.py +++ /dev/null @@ -1,192 +0,0 @@ -"""Support for eQ-3 Bluetooth Smart thermostats.""" -from __future__ import annotations - -import logging -from typing import Any - -import eq3bt as eq3 -import voluptuous as vol - -from homeassistant.components.climate import ( - PLATFORM_SCHEMA, - PRESET_AWAY, - PRESET_BOOST, - PRESET_NONE, - ClimateEntity, - ClimateEntityFeature, - HVACMode, -) -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_DEVICES, - CONF_MAC, - PRECISION_HALVES, - UnitOfTemperature, -) -from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import format_mac -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -from .const import PRESET_CLOSED, PRESET_NO_HOLD, PRESET_OPEN, PRESET_PERMANENT_HOLD - -_LOGGER = logging.getLogger(__name__) - -STATE_BOOST = "boost" - -ATTR_STATE_WINDOW_OPEN = "window_open" -ATTR_STATE_VALVE = "valve" -ATTR_STATE_LOCKED = "is_locked" -ATTR_STATE_LOW_BAT = "low_battery" -ATTR_STATE_AWAY_END = "away_end" - -EQ_TO_HA_HVAC = { - eq3.Mode.Open: HVACMode.HEAT, - eq3.Mode.Closed: HVACMode.OFF, - eq3.Mode.Auto: HVACMode.AUTO, - eq3.Mode.Manual: HVACMode.HEAT, - eq3.Mode.Boost: HVACMode.AUTO, - eq3.Mode.Away: HVACMode.HEAT, -} - -HA_TO_EQ_HVAC = { - HVACMode.HEAT: eq3.Mode.Manual, - HVACMode.OFF: eq3.Mode.Closed, - HVACMode.AUTO: eq3.Mode.Auto, -} - -EQ_TO_HA_PRESET = { - eq3.Mode.Boost: PRESET_BOOST, - eq3.Mode.Away: PRESET_AWAY, - eq3.Mode.Manual: PRESET_PERMANENT_HOLD, - eq3.Mode.Auto: PRESET_NO_HOLD, - eq3.Mode.Open: PRESET_OPEN, - eq3.Mode.Closed: PRESET_CLOSED, -} - -HA_TO_EQ_PRESET = { - PRESET_BOOST: eq3.Mode.Boost, - PRESET_AWAY: eq3.Mode.Away, - PRESET_PERMANENT_HOLD: eq3.Mode.Manual, - PRESET_NO_HOLD: eq3.Mode.Auto, - PRESET_OPEN: eq3.Mode.Open, - PRESET_CLOSED: eq3.Mode.Closed, -} - - -DEVICE_SCHEMA = vol.Schema({vol.Required(CONF_MAC): cv.string}) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_DEVICES): vol.Schema({cv.string: DEVICE_SCHEMA})} -) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the eQ-3 BLE thermostats.""" - devices = [] - - for name, device_cfg in config[CONF_DEVICES].items(): - mac = device_cfg[CONF_MAC] - devices.append(EQ3BTSmartThermostat(mac, name)) - - add_entities(devices, True) - - -class EQ3BTSmartThermostat(ClimateEntity): - """Representation of an eQ-3 Bluetooth Smart thermostat.""" - - _attr_hvac_modes = list(HA_TO_EQ_HVAC) - _attr_precision = PRECISION_HALVES - _attr_preset_modes = list(HA_TO_EQ_PRESET) - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE - ) - _attr_temperature_unit = UnitOfTemperature.CELSIUS - - def __init__(self, mac: str, name: str) -> None: - """Initialize the thermostat.""" - # We want to avoid name clash with this module. - self._attr_name = name - self._attr_unique_id = format_mac(mac) - self._thermostat = eq3.Thermostat(mac) - - @property - def available(self) -> bool: - """Return if thermostat is available.""" - return self._thermostat.mode >= 0 - - @property - def current_temperature(self): - """Can not report temperature, so return target_temperature.""" - return self.target_temperature - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._thermostat.target_temperature - - def set_temperature(self, **kwargs: Any) -> None: - """Set new target temperature.""" - if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: - return - self._thermostat.target_temperature = temperature - - @property - def hvac_mode(self) -> HVACMode: - """Return the current operation mode.""" - if self._thermostat.mode < 0: - return HVACMode.OFF - return EQ_TO_HA_HVAC[self._thermostat.mode] - - def set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set operation mode.""" - self._thermostat.mode = HA_TO_EQ_HVAC[hvac_mode] - - @property - def min_temp(self): - """Return the minimum temperature.""" - return self._thermostat.min_temp - - @property - def max_temp(self): - """Return the maximum temperature.""" - return self._thermostat.max_temp - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the device specific state attributes.""" - return { - ATTR_STATE_AWAY_END: self._thermostat.away_end, - ATTR_STATE_LOCKED: self._thermostat.locked, - ATTR_STATE_LOW_BAT: self._thermostat.low_battery, - ATTR_STATE_VALVE: self._thermostat.valve_state, - ATTR_STATE_WINDOW_OPEN: self._thermostat.window_open, - } - - @property - def preset_mode(self) -> str | None: - """Return the current preset mode, e.g., home, away, temp. - - Requires ClimateEntityFeature.PRESET_MODE. - """ - return EQ_TO_HA_PRESET.get(self._thermostat.mode) - - def set_preset_mode(self, preset_mode: str) -> None: - """Set new preset mode.""" - if preset_mode == PRESET_NONE: - self.set_hvac_mode(HVACMode.HEAT) - self._thermostat.mode = HA_TO_EQ_PRESET[preset_mode] - - def update(self) -> None: - """Update the data from the thermostat.""" - - try: - self._thermostat.update() - except eq3.BackendException as ex: - _LOGGER.warning("Updating the state failed: %s", ex) diff --git a/homeassistant/components/eq3btsmart/const.py b/homeassistant/components/eq3btsmart/const.py deleted file mode 100644 index af90acbde55..00000000000 --- a/homeassistant/components/eq3btsmart/const.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Constants for EQ3 Bluetooth Smart Radiator Valves.""" - -PRESET_PERMANENT_HOLD = "permanent_hold" -PRESET_NO_HOLD = "no_hold" -PRESET_OPEN = "open" -PRESET_CLOSED = "closed" diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json deleted file mode 100644 index 8a976b25c7a..00000000000 --- a/homeassistant/components/eq3btsmart/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "eq3btsmart", - "name": "eQ-3 Bluetooth Smart Thermostats", - "codeowners": ["@rytilahti"], - "dependencies": ["bluetooth_adapters"], - "documentation": "https://www.home-assistant.io/integrations/eq3btsmart", - "iot_class": "local_polling", - "loggers": ["bleak", "eq3bt"], - "requirements": ["construct==2.10.68", "python-eq3bt==0.2"] -} diff --git a/homeassistant/components/esphome/bluetooth/__init__.py b/homeassistant/components/esphome/bluetooth/__init__.py index 9ef298145d3..6936afac714 100644 --- a/homeassistant/components/esphome/bluetooth/__init__.py +++ b/homeassistant/components/esphome/bluetooth/__init__.py @@ -1,8 +1,11 @@ """Bluetooth support for esphome.""" from __future__ import annotations +import asyncio +from collections.abc import Coroutine from functools import partial import logging +from typing import Any from aioesphomeapi import APIClient, BluetoothProxyFeature @@ -43,6 +46,13 @@ def _async_can_connect( return can_connect +@hass_callback +def _async_unload(unload_callbacks: list[CALLBACK_TYPE]) -> None: + """Cancel all the callbacks on unload.""" + for callback in unload_callbacks: + callback() + + async def async_connect_scanner( hass: HomeAssistant, entry: ConfigEntry, @@ -92,27 +102,36 @@ async def async_connect_scanner( hass, source, entry.title, new_info_callback, connector, connectable ) client_data.scanner = scanner + coros: list[Coroutine[Any, Any, CALLBACK_TYPE]] = [] + # These calls all return a callback that can be used to unsubscribe + # but we never unsubscribe so we don't care about the return value + if connectable: # If its connectable be sure not to register the scanner # until we know the connection is fully setup since otherwise # there is a race condition where the connection can fail - await cli.subscribe_bluetooth_connections_free( - bluetooth_device.async_update_ble_connection_limits + coros.append( + cli.subscribe_bluetooth_connections_free( + bluetooth_device.async_update_ble_connection_limits + ) ) - unload_callbacks = [ - async_register_scanner(hass, scanner, connectable), - scanner.async_setup(), - ] + if feature_flags & BluetoothProxyFeature.RAW_ADVERTISEMENTS: - await cli.subscribe_bluetooth_le_raw_advertisements( - scanner.async_on_raw_advertisements + coros.append( + cli.subscribe_bluetooth_le_raw_advertisements( + scanner.async_on_raw_advertisements + ) ) else: - await cli.subscribe_bluetooth_le_advertisements(scanner.async_on_advertisement) + coros.append( + cli.subscribe_bluetooth_le_advertisements(scanner.async_on_advertisement) + ) - @hass_callback - def _async_unload() -> None: - for callback in unload_callbacks: - callback() - - return _async_unload + await asyncio.gather(*coros) + return partial( + _async_unload, + [ + async_register_scanner(hass, scanner, connectable), + scanner.async_setup(), + ], + ) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 22d4392ce31..96f1bce686a 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -22,6 +22,7 @@ from aioesphomeapi import ( APIClient, APIVersion, BLEConnectionError, + BluetoothConnectionDroppedError, BluetoothProxyFeature, DeviceInfo, ) @@ -30,7 +31,6 @@ from aioesphomeapi.core import ( BluetoothGATTAPIError, TimeoutAPIError, ) -from async_interrupt import interrupt from bleak.backends.characteristic import BleakGATTCharacteristic from bleak.backends.client import BaseBleakClient, NotifyCallback from bleak.backends.device import BLEDevice @@ -68,39 +68,25 @@ def mac_to_int(address: str) -> int: return int(address.replace(":", ""), 16) -def verify_connected(func: _WrapFuncType) -> _WrapFuncType: - """Define a wrapper throw BleakError if not connected.""" - - async def _async_wrap_bluetooth_connected_operation( - self: ESPHomeClient, *args: Any, **kwargs: Any - ) -> Any: - # pylint: disable=protected-access - if not self._is_connected: - raise BleakError(f"{self._description} is not connected") - loop = self._loop - disconnected_futures = self._disconnected_futures - disconnected_future = loop.create_future() - disconnected_futures.add(disconnected_future) - disconnect_message = f"{self._description}: Disconnected during operation" - try: - async with interrupt(disconnected_future, BleakError, disconnect_message): - return await func(self, *args, **kwargs) - finally: - disconnected_futures.discard(disconnected_future) - - return cast(_WrapFuncType, _async_wrap_bluetooth_connected_operation) - - def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType: """Define a wrapper throw esphome api errors as BleakErrors.""" async def _async_wrap_bluetooth_operation( self: ESPHomeClient, *args: Any, **kwargs: Any ) -> Any: + # pylint: disable=protected-access try: return await func(self, *args, **kwargs) except TimeoutAPIError as err: raise asyncio.TimeoutError(str(err)) from err + except BluetoothConnectionDroppedError as ex: + _LOGGER.debug( + "%s: BLE device disconnected during %s operation", + self._description, + func.__name__, + ) + self._async_ble_device_disconnected() + raise BleakError(str(ex)) from ex except BluetoothGATTAPIError as ex: # If the device disconnects in the middle of an operation # be sure to mark it as disconnected so any library using @@ -111,7 +97,6 @@ def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType: # before the callback is delivered. if ex.error.error == -1: - # pylint: disable=protected-access _LOGGER.debug( "%s: BLE device disconnected during %s operation", self._description, @@ -169,7 +154,6 @@ class ESPHomeClient(BaseBleakClient): self._notify_cancels: dict[ int, tuple[Callable[[], Coroutine[Any, Any, None]], Callable[[], None]] ] = {} - self._disconnected_futures: set[asyncio.Future[None]] = set() self._device_info = client_data.device_info self._feature_flags = device_info.bluetooth_proxy_feature_flags_compat( client_data.api_version @@ -185,24 +169,7 @@ class ESPHomeClient(BaseBleakClient): def __str__(self) -> str: """Return the string representation of the client.""" - return f"ESPHomeClient ({self.address})" - - def _unsubscribe_connection_state(self) -> None: - """Unsubscribe from connection state updates.""" - if not self._cancel_connection_state: - return - try: - self._cancel_connection_state() - except (AssertionError, ValueError) as ex: - _LOGGER.debug( - ( - "%s: Failed to unsubscribe from connection state (likely" - " connection dropped): %s" - ), - self._description, - ex, - ) - self._cancel_connection_state = None + return f"ESPHomeClient ({self._description})" def _async_disconnected_cleanup(self) -> None: """Clean up on disconnect.""" @@ -211,12 +178,10 @@ class ESPHomeClient(BaseBleakClient): for _, notify_abort in self._notify_cancels.values(): notify_abort() self._notify_cancels.clear() - for future in self._disconnected_futures: - if not future.done(): - future.set_result(None) - self._disconnected_futures.clear() self._disconnect_callbacks.discard(self._async_esp_disconnected) - self._unsubscribe_connection_state() + if self._cancel_connection_state: + self._cancel_connection_state() + self._cancel_connection_state = None def _async_ble_device_disconnected(self) -> None: """Handle the BLE device disconnecting from the ESP.""" @@ -406,7 +371,6 @@ class ESPHomeClient(BaseBleakClient): """Get ATT MTU size for active connection.""" return self._mtu or DEFAULT_MTU - @verify_connected @api_error_as_bleak_error async def pair(self, *args: Any, **kwargs: Any) -> bool: """Attempt to pair.""" @@ -415,6 +379,7 @@ class ESPHomeClient(BaseBleakClient): "Pairing is not available in this version ESPHome; " f"Upgrade the ESPHome version on the {self._device_info.name} device." ) + self._raise_if_not_connected() response = await self._client.bluetooth_device_pair(self._address_as_int) if response.paired: return True @@ -423,7 +388,6 @@ class ESPHomeClient(BaseBleakClient): ) return False - @verify_connected @api_error_as_bleak_error async def unpair(self) -> bool: """Attempt to unpair.""" @@ -432,6 +396,7 @@ class ESPHomeClient(BaseBleakClient): "Unpairing is not available in this version ESPHome; " f"Upgrade the ESPHome version on the {self._device_info.name} device." ) + self._raise_if_not_connected() response = await self._client.bluetooth_device_unpair(self._address_as_int) if response.success: return True @@ -454,7 +419,6 @@ class ESPHomeClient(BaseBleakClient): dangerous_use_bleak_cache=dangerous_use_bleak_cache, **kwargs ) - @verify_connected async def _get_services( self, dangerous_use_bleak_cache: bool = False, **kwargs: Any ) -> BleakGATTServiceCollection: @@ -462,6 +426,7 @@ class ESPHomeClient(BaseBleakClient): Must only be called from get_services or connected """ + self._raise_if_not_connected() address_as_int = self._address_as_int cache = self._cache # If the connection version >= 3, we must use the cache @@ -527,7 +492,6 @@ class ESPHomeClient(BaseBleakClient): ) return characteristic - @verify_connected @api_error_as_bleak_error async def clear_cache(self) -> bool: """Clear the GATT cache.""" @@ -541,6 +505,7 @@ class ESPHomeClient(BaseBleakClient): self._device_info.name, ) return True + self._raise_if_not_connected() response = await self._client.bluetooth_device_clear_cache(self._address_as_int) if response.success: return True @@ -551,7 +516,6 @@ class ESPHomeClient(BaseBleakClient): ) return False - @verify_connected @api_error_as_bleak_error async def read_gatt_char( self, @@ -570,12 +534,12 @@ class ESPHomeClient(BaseBleakClient): Returns: (bytearray) The read data. """ + self._raise_if_not_connected() characteristic = self._resolve_characteristic(char_specifier) return await self._client.bluetooth_gatt_read( self._address_as_int, characteristic.handle, GATT_READ_TIMEOUT ) - @verify_connected @api_error_as_bleak_error async def read_gatt_descriptor(self, handle: int, **kwargs: Any) -> bytearray: """Perform read operation on the specified GATT descriptor. @@ -587,11 +551,11 @@ class ESPHomeClient(BaseBleakClient): Returns: (bytearray) The read data. """ + self._raise_if_not_connected() return await self._client.bluetooth_gatt_read_descriptor( self._address_as_int, handle, GATT_READ_TIMEOUT ) - @verify_connected @api_error_as_bleak_error async def write_gatt_char( self, @@ -610,12 +574,12 @@ class ESPHomeClient(BaseBleakClient): response (bool): If write-with-response operation should be done. Defaults to `False`. """ + self._raise_if_not_connected() characteristic = self._resolve_characteristic(characteristic) await self._client.bluetooth_gatt_write( self._address_as_int, characteristic.handle, bytes(data), response ) - @verify_connected @api_error_as_bleak_error async def write_gatt_descriptor(self, handle: int, data: Buffer) -> None: """Perform a write operation on the specified GATT descriptor. @@ -624,11 +588,11 @@ class ESPHomeClient(BaseBleakClient): handle (int): The handle of the descriptor to read from. data (bytes or bytearray): The data to send. """ + self._raise_if_not_connected() await self._client.bluetooth_gatt_write_descriptor( self._address_as_int, handle, bytes(data) ) - @verify_connected @api_error_as_bleak_error async def start_notify( self, @@ -655,6 +619,7 @@ class ESPHomeClient(BaseBleakClient): callback (function): The function to be called on notification. kwargs: Unused. """ + self._raise_if_not_connected() ble_handle = characteristic.handle if ble_handle in self._notify_cancels: raise BleakError( @@ -709,7 +674,6 @@ class ESPHomeClient(BaseBleakClient): wait_for_response=False, ) - @verify_connected @api_error_as_bleak_error async def stop_notify( self, @@ -723,6 +687,7 @@ class ESPHomeClient(BaseBleakClient): specified by either integer handle, UUID or directly by the BleakGATTCharacteristic object representing it. """ + self._raise_if_not_connected() characteristic = self._resolve_characteristic(char_specifier) # Do not raise KeyError if notifications are not enabled on this characteristic # to be consistent with the behavior of the BlueZ backend @@ -730,6 +695,11 @@ class ESPHomeClient(BaseBleakClient): notify_stop, _ = notify_cancel await notify_stop() + def _raise_if_not_connected(self) -> None: + """Raise a BleakError if not connected.""" + if not self._is_connected: + raise BleakError(f"{self._description} is not connected") + def __del__(self) -> None: """Destructor to make sure the connection state is unsubscribed.""" if self._cancel_connection_state: diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index b34714ff89c..08ed2f1109d 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -164,11 +164,15 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti ) self._attr_min_temp = static_info.visual_min_temperature self._attr_max_temp = static_info.visual_max_temperature + self._attr_min_humidity = round(static_info.visual_min_humidity) + self._attr_max_humidity = round(static_info.visual_max_humidity) features = ClimateEntityFeature(0) if self._static_info.supports_two_point_target_temperature: features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE else: features |= ClimateEntityFeature.TARGET_TEMPERATURE + if self._static_info.supports_target_humidity: + features |= ClimateEntityFeature.TARGET_HUMIDITY if self.preset_modes: features |= ClimateEntityFeature.PRESET_MODE if self.fan_modes: @@ -234,6 +238,14 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti """Return the current temperature.""" return self._state.current_temperature + @property + @esphome_state_property + def current_humidity(self) -> int | None: + """Return the current humidity.""" + if not self._static_info.supports_current_humidity: + return None + return round(self._state.current_humidity) + @property @esphome_state_property def target_temperature(self) -> float | None: @@ -252,6 +264,12 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti """Return the highbound target temperature we try to reach.""" return self._state.target_temperature_high + @property + @esphome_state_property + def target_humidity(self) -> int: + """Return the humidity we try to reach.""" + return round(self._state.target_humidity) + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature (and operation mode if set).""" data: dict[str, Any] = {"key": self._key} @@ -267,6 +285,10 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti data["target_temperature_high"] = kwargs[ATTR_TARGET_TEMP_HIGH] await self._client.climate_command(**data) + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + await self._client.climate_command(key=self._key, target_humidity=humidity) + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target operation mode.""" await self._client.climate_command( diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 89629a65ea5..d69a30a8c1a 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -321,7 +321,6 @@ class RuntimeEntryData: current_state_by_type = self.state[state_type] current_state = current_state_by_type.get(key, _SENTINEL) subscription_key = (state_type, key) - debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) if ( current_state == state and subscription_key not in stale_state @@ -333,21 +332,7 @@ class RuntimeEntryData: and (cast(SensorInfo, entity_info)).force_update ) ): - if debug_enabled: - _LOGGER.debug( - "%s: ignoring duplicate update with key %s: %s", - self.name, - key, - state, - ) return - if debug_enabled: - _LOGGER.debug( - "%s: dispatching update with key %s: %s", - self.name, - key, - state, - ) stale_state.discard(subscription_key) current_state_by_type[key] = state if subscription := self.state_subscriptions.get(subscription_key): diff --git a/homeassistant/components/esphome/enum_mapper.py b/homeassistant/components/esphome/enum_mapper.py index 566f0bc503b..fd09f9a05b6 100644 --- a/homeassistant/components/esphome/enum_mapper.py +++ b/homeassistant/components/esphome/enum_mapper.py @@ -14,9 +14,7 @@ class EsphomeEnumMapper(Generic[_EnumT, _ValT]): def __init__(self, mapping: dict[_EnumT, _ValT]) -> None: """Construct a EsphomeEnumMapper.""" # Add none mapping - augmented_mapping: dict[ - _EnumT | None, _ValT | None - ] = mapping # type: ignore[assignment] + augmented_mapping: dict[_EnumT | None, _ValT | None] = mapping # type: ignore[assignment] augmented_mapping[None] = None self._mapping = augmented_mapping diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index a6ca52d6c1a..9942498e12d 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -117,7 +117,8 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): """Return the current speed percentage.""" if not self._supports_speed_levels: return ordered_list_item_to_percentage( - ORDERED_NAMED_FAN_SPEEDS, self._state.speed # type: ignore[misc] + ORDERED_NAMED_FAN_SPEEDS, + self._state.speed, # type: ignore[misc] ) return ranged_value_to_percentage( diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index ad226e04061..79e8a0a06fa 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -1,6 +1,8 @@ """Manager for esphome devices.""" from __future__ import annotations +import asyncio +from collections.abc import Coroutine import logging from typing import TYPE_CHECKING, Any, NamedTuple @@ -9,6 +11,7 @@ from aioesphomeapi import ( APIConnectionError, APIVersion, DeviceInfo as EsphomeDeviceInfo, + EntityInfo, HomeassistantServiceCall, InvalidAuthAPIError, InvalidEncryptionKeyAPIError, @@ -24,8 +27,20 @@ import voluptuous as vol from homeassistant.components import tag, zeroconf from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_DEVICE_ID, CONF_MODE, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, HomeAssistant, ServiceCall, State, callback +from homeassistant.const import ( + ATTR_DEVICE_ID, + CONF_MODE, + EVENT_HOMEASSISTANT_STOP, + EVENT_LOGGING_CHANGED, +) +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + HomeAssistant, + ServiceCall, + State, + callback, +) from homeassistant.exceptions import TemplateError from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv @@ -371,13 +386,20 @@ class ESPHomeManager: stored_device_name = entry.data.get(CONF_DEVICE_NAME) unique_id_is_mac_address = unique_id and ":" in unique_id try: - device_info = await cli.device_info() + results = await asyncio.gather( + cli.device_info(), + cli.list_entities_services(), + ) except APIConnectionError as err: _LOGGER.warning("Error getting device info for %s: %s", self.host, err) # Re-connection logic will trigger after this await cli.disconnect() return + device_info: EsphomeDeviceInfo = results[0] + entity_infos_services: tuple[list[EntityInfo], list[UserService]] = results[1] + entity_infos, services = entity_infos_services + device_mac = format_mac(device_info.mac_address) mac_address_matches = unique_id == device_mac # @@ -438,42 +460,55 @@ class ESPHomeManager: if device_info.name: reconnect_logic.name = device_info.name + self.device_id = _async_setup_device_registry(hass, entry, entry_data) + entry_data.async_update_device_state(hass) + await asyncio.gather( + entry_data.async_update_static_infos( + hass, entry, entity_infos, device_info.mac_address + ), + _setup_services(hass, entry_data, services), + ) + + setup_coros_with_disconnect_callbacks: list[ + Coroutine[Any, Any, CALLBACK_TYPE] + ] = [] if device_info.bluetooth_proxy_feature_flags_compat(cli.api_version): - entry_data.disconnect_callbacks.add( - await async_connect_scanner( + setup_coros_with_disconnect_callbacks.append( + async_connect_scanner( hass, entry, cli, entry_data, self.domain_data.bluetooth_cache ) ) - self.device_id = _async_setup_device_registry(hass, entry, entry_data) - entry_data.async_update_device_state(hass) + if device_info.voice_assistant_version: + setup_coros_with_disconnect_callbacks.append( + cli.subscribe_voice_assistant( + self._handle_pipeline_start, + self._handle_pipeline_stop, + ) + ) try: - entity_infos, services = await cli.list_entities_services() - await entry_data.async_update_static_infos( - hass, entry, entity_infos, device_info.mac_address + setup_results = await asyncio.gather( + *setup_coros_with_disconnect_callbacks, + cli.subscribe_states(entry_data.async_update_state), + cli.subscribe_service_calls(self.async_on_service_call), + cli.subscribe_home_assistant_states(self.async_on_state_subscription), ) - await _setup_services(hass, entry_data, services) - await cli.subscribe_states(entry_data.async_update_state) - await cli.subscribe_service_calls(self.async_on_service_call) - await cli.subscribe_home_assistant_states(self.async_on_state_subscription) - - if device_info.voice_assistant_version: - entry_data.disconnect_callbacks.add( - await cli.subscribe_voice_assistant( - self._handle_pipeline_start, - self._handle_pipeline_stop, - ) - ) - - hass.async_create_task(entry_data.async_save_to_store()) except APIConnectionError as err: _LOGGER.warning("Error getting initial data for %s: %s", self.host, err) # Re-connection logic will trigger after this await cli.disconnect() - else: - _async_check_firmware_version(hass, device_info, entry_data.api_version) - _async_check_using_api_password(hass, device_info, bool(self.password)) + return + + for result_idx in range(len(setup_coros_with_disconnect_callbacks)): + cancel_callback = setup_results[result_idx] + if TYPE_CHECKING: + assert cancel_callback is not None + entry_data.disconnect_callbacks.add(cancel_callback) + + hass.async_create_task(entry_data.async_save_to_store()) + _async_check_firmware_version(hass, device_info, entry_data.api_version) + _async_check_using_api_password(hass, device_info, bool(self.password)) async def on_disconnect(self, expected_disconnect: bool) -> None: """Run disconnect callbacks on API disconnect.""" @@ -515,6 +550,11 @@ class ESPHomeManager: ): self.entry.async_start_reauth(self.hass) + @callback + def _async_handle_logging_changed(self, _event: Event) -> None: + """Handle when the logging level changes.""" + self.cli.set_debug(_LOGGER.isEnabledFor(logging.DEBUG)) + async def async_start(self) -> None: """Start the esphome connection manager.""" hass = self.hass @@ -531,6 +571,11 @@ class ESPHomeManager: entry_data.cleanup_callbacks.append( hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, self.on_stop) ) + entry_data.cleanup_callbacks.append( + hass.bus.async_listen( + EVENT_LOGGING_CHANGED, self._async_handle_logging_changed + ) + ) reconnect_logic = ReconnectLogic( client=self.cli, diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index cb1a741c447..7eca285681d 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,9 +15,8 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "async-interrupt==1.1.1", - "aioesphomeapi==18.2.4", - "bluetooth-data-tools==1.14.0", + "aioesphomeapi==19.2.1", + "bluetooth-data-tools==1.15.0", "esphome-dashboard-api==1.2.3" ], "zeroconf": ["_esphomelib._tcp.local."] diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index 26c0780d735..de6b521d980 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -3,9 +3,11 @@ from __future__ import annotations import asyncio from collections.abc import AsyncIterable, Callable +import io import logging import socket from typing import cast +import wave from aioesphomeapi import ( VoiceAssistantAudioSettings, @@ -88,6 +90,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): self.handle_event = handle_event self.handle_finished = handle_finished self._tts_done = asyncio.Event() + self._tts_task: asyncio.Task | None = None async def start_server(self) -> int: """Start accepting connections.""" @@ -183,16 +186,22 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): data_to_send = {"text": event.data["tts_input"]} elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: assert event.data is not None - path = event.data["tts_output"]["url"] - url = async_process_play_media_url(self.hass, path) - data_to_send = {"url": url} + tts_output = event.data["tts_output"] + if tts_output: + path = tts_output["url"] + url = async_process_play_media_url(self.hass, path) + data_to_send = {"url": url} - if self.device_info.voice_assistant_version >= 2: - media_id = event.data["tts_output"]["media_id"] - self.hass.async_create_background_task( - self._send_tts(media_id), "esphome_voice_assistant_tts" - ) + if self.device_info.voice_assistant_version >= 2: + media_id = tts_output["media_id"] + self._tts_task = self.hass.async_create_background_task( + self._send_tts(media_id), "esphome_voice_assistant_tts" + ) + else: + self._tts_done.set() else: + # Empty TTS response + data_to_send = {} self._tts_done.set() elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END: assert event.data is not None @@ -228,7 +237,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): audio_settings = VoiceAssistantAudioSettings() tts_audio_output = ( - "raw" if self.device_info.voice_assistant_version >= 2 else "mp3" + "wav" if self.device_info.voice_assistant_version >= 2 else "mp3" ) _LOGGER.debug("Starting pipeline") @@ -298,20 +307,43 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): if self.transport is None: return - self.handle_event( - VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_START, {} - ) - - _extension, audio_bytes = await tts.async_get_media_source_audio( + extension, data = await tts.async_get_media_source_audio( self.hass, media_id, ) - _LOGGER.debug("Sending %d bytes of audio", len(audio_bytes)) + if extension != "wav": + raise ValueError(f"Only WAV audio can be streamed, got {extension}") + + with io.BytesIO(data) as wav_io: + with wave.open(wav_io, "rb") as wav_file: + sample_rate = wav_file.getframerate() + sample_width = wav_file.getsampwidth() + sample_channels = wav_file.getnchannels() + + if ( + (sample_rate != 16000) + or (sample_width != 2) + or (sample_channels != 1) + ): + raise ValueError( + "Expected rate/width/channels as 16000/2/1," + " got {sample_rate}/{sample_width}/{sample_channels}}" + ) + + audio_bytes = wav_file.readframes(wav_file.getnframes()) + + audio_bytes_size = len(audio_bytes) + + _LOGGER.debug("Sending %d bytes of audio", audio_bytes_size) + + self.handle_event( + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_START, {} + ) bytes_per_sample = stt.AudioBitRates.BITRATE_16 // 8 sample_offset = 0 - samples_left = len(audio_bytes) // bytes_per_sample + samples_left = audio_bytes_size // bytes_per_sample while samples_left > 0: bytes_offset = sample_offset * bytes_per_sample @@ -330,4 +362,5 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): self.handle_event( VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_END, {} ) + self._tts_task = None self._tts_done.set() diff --git a/homeassistant/components/evil_genius_labs/strings.json b/homeassistant/components/evil_genius_labs/strings.json index 790e9a69c7f..123d164444d 100644 --- a/homeassistant/components/evil_genius_labs/strings.json +++ b/homeassistant/components/evil_genius_labs/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Evil Genius Labs device." } } }, diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 4b79ef3df1b..f4ceaf2c48c 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -10,7 +10,6 @@ import logging import re from typing import Any -import aiohttp.client_exceptions import evohomeasync import evohomeasync2 import voluptuous as vol @@ -125,10 +124,13 @@ def convert_dict(dictionary: dict[str, Any]) -> dict[str, Any]: def convert_key(key: str) -> str: """Convert a string to snake_case.""" string = re.sub(r"[\-\.\s]", "_", str(key)) - return (string[0]).lower() + re.sub( - r"[A-Z]", - lambda matched: f"_{matched.group(0).lower()}", # type:ignore[str-bytes-safe] - string[1:], + return ( + (string[0]).lower() + + re.sub( + r"[A-Z]", + lambda matched: f"_{matched.group(0).lower()}", # type:ignore[str-bytes-safe] + string[1:], + ) ) return { @@ -144,7 +146,7 @@ def _handle_exception(err) -> None: try: raise err - except evohomeasync2.AuthenticationError: + except evohomeasync2.AuthenticationFailed: _LOGGER.error( ( "Failed to authenticate with the vendor's server. Check your username" @@ -155,19 +157,18 @@ def _handle_exception(err) -> None: err, ) - except aiohttp.ClientConnectionError: - # this appears to be a common occurrence with the vendor's servers - _LOGGER.warning( - ( - "Unable to connect with the vendor's server. " - "Check your network and the vendor's service status page. " - "Message is: %s" - ), - err, - ) + except evohomeasync2.RequestFailed: + if err.status is None: + _LOGGER.warning( + ( + "Unable to connect with the vendor's server. " + "Check your network and the vendor's service status page. " + "Message is: %s" + ), + err, + ) - except aiohttp.ClientResponseError: - if err.status == HTTPStatus.SERVICE_UNAVAILABLE: + elif err.status == HTTPStatus.SERVICE_UNAVAILABLE: _LOGGER.warning( "The vendor says their server is currently unavailable. " "Check the vendor's service status page" @@ -219,7 +220,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: try: await client_v2.login() - except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: + except evohomeasync2.AuthenticationFailed as err: _handle_exception(err) return False finally: @@ -429,7 +430,9 @@ class EvoBroker: async def save_auth_tokens(self) -> None: """Save access tokens and session IDs to the store for later use.""" # evohomeasync2 uses naive/local datetimes - access_token_expires = _dt_local_to_aware(self.client.access_token_expires) + access_token_expires = _dt_local_to_aware( + self.client.access_token_expires # type: ignore[arg-type] + ) app_storage = { CONF_USERNAME: self.client.username, @@ -439,8 +442,9 @@ class EvoBroker: } if self.client_v1 and self.client_v1.user_data: - app_storage[USER_DATA] = { - "userInfo": {"userID": self.client_v1.user_data["userInfo"]["userID"]}, + user_id = self.client_v1.user_data["userInfo"]["userID"] # type: ignore[index] + app_storage[USER_DATA] = { # type: ignore[assignment] + "userInfo": {"userID": user_id}, "sessionId": self.client_v1.user_data["sessionId"], } else: @@ -452,7 +456,7 @@ class EvoBroker: """Call a client API and update the broker state if required.""" try: result = await api_function - except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: + except evohomeasync2.EvohomeError as err: _handle_exception(err) return @@ -475,19 +479,7 @@ class EvoBroker: try: temps = list(await self.client_v1.temperatures(force_refresh=True)) - except aiohttp.ClientError as err: - _LOGGER.warning( - ( - "Unable to obtain the latest high-precision temperatures. " - "Check your network and the vendor's service status page. " - "Proceeding with low-precision temperatures. " - "Message is: %s" - ), - err, - ) - self.temps = None # these are now stale, will fall back to v2 temps - - except KeyError as err: + except evohomeasync.InvalidSchema as exc: _LOGGER.warning( ( "Unable to obtain high-precision temperatures. " @@ -495,9 +487,21 @@ class EvoBroker: "so the high-precision feature will be disabled until next restart." "Message is: %s" ), - err, + exc, ) - self.client_v1 = self.temps = None + self.temps = self.client_v1 = None + + except evohomeasync.EvohomeError as exc: + _LOGGER.warning( + ( + "Unable to obtain the latest high-precision temperatures. " + "Check your network and the vendor's service status page. " + "Proceeding without high-precision temperatures for now. " + "Message is: %s" + ), + exc, + ) + self.temps = None # these are now stale, will fall back to v2 temps else: if ( @@ -509,14 +513,15 @@ class EvoBroker: "the v1 API's default location (there is more than one location), " "so the high-precision feature will be disabled until next restart" ) - self.client_v1 = self.temps = None + self.temps = self.client_v1 = None else: self.temps = {str(i["id"]): i["temp"] for i in temps} - _LOGGER.debug("Temperatures = %s", self.temps) + finally: + if session_id != get_session_id(self.client_v1): + await self.save_auth_tokens() - if session_id != get_session_id(self.client_v1): - await self.save_auth_tokens() + _LOGGER.debug("Temperatures = %s", self.temps) async def _update_v2_api_state(self, *args, **kwargs) -> None: """Get the latest modes, temperatures, setpoints of a Location.""" @@ -524,8 +529,8 @@ class EvoBroker: loc_idx = self.params[CONF_LOCATION_IDX] try: - status = await self.client.locations[loc_idx].status() - except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: + status = await self.client.locations[loc_idx].refresh_status() + except evohomeasync2.EvohomeError as err: _handle_exception(err) else: async_dispatcher_send(self.hass, DOMAIN) @@ -542,11 +547,11 @@ class EvoBroker: operating mode of the Controller and the current temp of its children (e.g. Zones, DHW controller). """ + await self._update_v2_api_state() + if self.client_v1: await self._update_v1_api_temps() - await self._update_v2_api_state() - class EvoDevice(Entity): """Base for any evohome device. @@ -618,11 +623,13 @@ class EvoChild(EvoDevice): @property def current_temperature(self) -> float | None: """Return the current temperature of a Zone.""" - if ( - self._evo_broker.temps - and self._evo_broker.temps[self._evo_device.zoneId] != 128 - ): - return self._evo_broker.temps[self._evo_device.zoneId] + if self._evo_device.TYPE == "domesticHotWater": + dev_id = self._evo_device.dhwId + else: + dev_id = self._evo_device.zoneId + + if self._evo_broker.temps and self._evo_broker.temps[dev_id] is not None: + return self._evo_broker.temps[dev_id] if self._evo_device.temperatureStatus["isAvailable"]: return self._evo_device.temperatureStatus["temperature"] @@ -695,7 +702,7 @@ class EvoChild(EvoDevice): async def _update_schedule(self) -> None: """Get the latest schedule, if any.""" self._schedule = await self._evo_broker.call_client_api( - self._evo_device.schedule(), update_state=False + self._evo_device.get_schedule(), update_state=False ) _LOGGER.debug("Schedule['%s'] = %s", self.name, self._schedule) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 3bee1d6062e..fb608262a7d 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -167,9 +167,7 @@ class EvoZone(EvoChild, EvoClimateEntity): async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> None: """Process a service request (setpoint override) for a zone.""" if service == SVC_RESET_ZONE_OVERRIDE: - await self._evo_broker.call_client_api( - self._evo_device.cancel_temp_override() - ) + await self._evo_broker.call_client_api(self._evo_device.reset_mode()) return # otherwise it is SVC_SET_ZONE_OVERRIDE @@ -264,18 +262,14 @@ class EvoZone(EvoChild, EvoClimateEntity): self._evo_device.set_temperature(self.min_temp, until=None) ) else: # HVACMode.HEAT - await self._evo_broker.call_client_api( - self._evo_device.cancel_temp_override() - ) + await self._evo_broker.call_client_api(self._evo_device.reset_mode()) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode; if None, then revert to following the schedule.""" evo_preset_mode = HA_PRESET_TO_EVO.get(preset_mode, EVO_FOLLOW) if evo_preset_mode == EVO_FOLLOW: - await self._evo_broker.call_client_api( - self._evo_device.cancel_temp_override() - ) + await self._evo_broker.call_client_api(self._evo_device.reset_mode()) return temperature = self._evo_device.setpointStatus["targetHeatTemperature"] @@ -352,7 +346,7 @@ class EvoController(EvoClimateEntity): """Set a Controller to any of its native EVO_* operating modes.""" until = dt_util.as_utc(until) if until else None await self._evo_broker.call_client_api( - self._evo_tcs.set_status(mode, until=until) + self._evo_tcs.set_mode(mode, until=until) ) @property diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 641833ef06a..58efb2c25b2 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/evohome", "iot_class": "cloud_polling", "loggers": ["evohomeasync", "evohomeasync2"], - "requirements": ["evohome-async==0.3.15"] + "requirements": ["evohome-async==0.4.6"] } diff --git a/homeassistant/components/evohome/services.yaml b/homeassistant/components/evohome/services.yaml index a16395ad6c0..60dcf37ebb0 100644 --- a/homeassistant/components/evohome/services.yaml +++ b/homeassistant/components/evohome/services.yaml @@ -24,7 +24,9 @@ set_system_mode: object: reset_system: + refresh_system: + set_zone_override: fields: entity_id: diff --git a/homeassistant/components/evohome/strings.json b/homeassistant/components/evohome/strings.json index aa38ee170a5..9e88c9bb031 100644 --- a/homeassistant/components/evohome/strings.json +++ b/homeassistant/components/evohome/strings.json @@ -6,7 +6,7 @@ "fields": { "mode": { "name": "[%key:common::config_flow::data::mode%]", - "description": "Mode to set thermostat." + "description": "Mode to set the system to." }, "period": { "name": "Period", diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index 87c0a8a1ecd..5d49e9b46ec 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -46,9 +46,10 @@ async def async_setup_platform( _LOGGER.debug( "Adding: DhwController (%s), id=%s", - broker.tcs.hotwater.zone_type, - broker.tcs.hotwater.zoneId, + broker.tcs.hotwater.TYPE, + broker.tcs.hotwater.dhwId, ) + new_entity = EvoDHW(broker, broker.tcs.hotwater) async_add_entities([new_entity], update_before_add=True) @@ -95,7 +96,7 @@ class EvoDHW(EvoChild, WaterHeaterEntity): Except for Auto, the mode is only until the next SetPoint. """ if operation_mode == STATE_AUTO: - await self._evo_broker.call_client_api(self._evo_device.set_dhw_auto()) + await self._evo_broker.call_client_api(self._evo_device.reset_mode()) else: await self._update_schedule() until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) @@ -103,28 +104,28 @@ class EvoDHW(EvoChild, WaterHeaterEntity): if operation_mode == STATE_ON: await self._evo_broker.call_client_api( - self._evo_device.set_dhw_on(until=until) + self._evo_device.set_on(until=until) ) else: # STATE_OFF await self._evo_broker.call_client_api( - self._evo_device.set_dhw_off(until=until) + self._evo_device.set_off(until=until) ) async def async_turn_away_mode_on(self) -> None: """Turn away mode on.""" - await self._evo_broker.call_client_api(self._evo_device.set_dhw_off()) + await self._evo_broker.call_client_api(self._evo_device.set_off()) async def async_turn_away_mode_off(self) -> None: """Turn away mode off.""" - await self._evo_broker.call_client_api(self._evo_device.set_dhw_auto()) + await self._evo_broker.call_client_api(self._evo_device.reset_mode()) async def async_turn_on(self): """Turn on.""" - await self._evo_broker.call_client_api(self._evo_device.set_dhw_on()) + await self._evo_broker.call_client_api(self._evo_device.set_on()) async def async_turn_off(self): """Turn off.""" - await self._evo_broker.call_client_api(self._evo_device.set_dhw_off()) + await self._evo_broker.call_client_api(self._evo_device.set_off()) async def async_update(self) -> None: """Get the latest state data for a DHW controller.""" diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 85b1f316a7b..e42968603e4 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -33,7 +33,6 @@ from .const import ( ATTR_LEVEL, ATTR_SERIAL, ATTR_SPEED, - ATTR_TYPE, CONF_FFMPEG_ARGUMENTS, DATA_COORDINATOR, DEFAULT_CAMERA_USERNAME, @@ -45,7 +44,6 @@ from .const import ( DOMAIN, SERVICE_ALARM_SOUND, SERVICE_ALARM_TRIGGER, - SERVICE_DETECTION_SENSITIVITY, SERVICE_PTZ, SERVICE_WAKE_DEVICE, ) @@ -157,15 +155,6 @@ async def async_setup_entry( "perform_alarm_sound", ) - platform.async_register_entity_service( - SERVICE_DETECTION_SENSITIVITY, - { - vol.Required(ATTR_LEVEL): cv.positive_int, - vol.Required(ATTR_TYPE): cv.positive_int, - }, - "perform_set_alarm_detection_sensibility", - ) - class EzvizCamera(EzvizEntity, Camera): """An implementation of a EZVIZ security camera.""" @@ -329,25 +318,3 @@ class EzvizCamera(EzvizEntity, Camera): raise HTTPError( "Cannot set alarm sound level for on movement detected" ) from err - - def perform_set_alarm_detection_sensibility( - self, level: int, type_value: int - ) -> None: - """Set camera detection sensibility level service.""" - try: - self.coordinator.ezviz_client.detection_sensibility( - self._serial, level, type_value - ) - except (HTTPError, PyEzvizError) as err: - raise PyEzvizError("Cannot set detection sensitivity level") from err - - ir.async_create_issue( - self.hass, - DOMAIN, - "service_depreciation_detection_sensibility", - breaks_in_ha_version="2023.12.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="service_depreciation_detection_sensibility", - ) diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index 11144f8ae71..11ec31fee4a 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -60,17 +60,6 @@ } }, "issues": { - "service_depreciation_detection_sensibility": { - "title": "Ezviz Detection sensitivity service is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::ezviz::issues::service_depreciation_detection_sensibility::title%]", - "description": "The Ezviz Detection sensitivity service is deprecated and will be removed in Home Assistant 2023.12.\nTo set the sensitivity, you can instead use the `number.set_value` service targetting the Detection sensitivity entity.\n\nPlease remove this service from your automations and scripts and select **submit** to close this issue." - } - } - } - }, "service_deprecation_alarm_sound_level": { "title": "Ezviz Alarm sound level service is being removed", "fix_flow": { diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index a149909e029..21ffca35962 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -18,7 +18,8 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_ON, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -77,8 +78,19 @@ ATTR_PRESET_MODES = "preset_modes" # mypy: disallow-any-generics -class NotValidPresetModeError(ValueError): - """Exception class when the preset_mode in not in the preset_modes list.""" +class NotValidPresetModeError(ServiceValidationError): + """Raised when the preset_mode is not in the preset_modes list.""" + + def __init__( + self, *args: object, translation_placeholders: dict[str, str] | None = None + ) -> None: + """Initialize the exception.""" + super().__init__( + *args, + translation_domain=DOMAIN, + translation_key="not_valid_preset_mode", + translation_placeholders=translation_placeholders, + ) @bind_hass @@ -107,7 +119,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ), vol.Optional(ATTR_PRESET_MODE): cv.string, }, - "async_turn_on", + "async_handle_turn_on_service", ) component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off") component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle") @@ -156,7 +168,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( SERVICE_SET_PRESET_MODE, {vol.Required(ATTR_PRESET_MODE): cv.string}, - "async_set_preset_mode", + "async_handle_set_preset_mode_service", [FanEntityFeature.SET_SPEED, FanEntityFeature.PRESET_MODE], ) @@ -237,17 +249,30 @@ class FanEntity(ToggleEntity): """Set new preset mode.""" raise NotImplementedError() + @final + async def async_handle_set_preset_mode_service(self, preset_mode: str) -> None: + """Validate and set new preset mode.""" + self._valid_preset_mode_or_raise(preset_mode) + await self.async_set_preset_mode(preset_mode) + async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" await self.hass.async_add_executor_job(self.set_preset_mode, preset_mode) + @final + @callback def _valid_preset_mode_or_raise(self, preset_mode: str) -> None: """Raise NotValidPresetModeError on invalid preset_mode.""" preset_modes = self.preset_modes if not preset_modes or preset_mode not in preset_modes: + preset_modes_str: str = ", ".join(preset_modes or []) raise NotValidPresetModeError( f"The preset_mode {preset_mode} is not a valid preset_mode:" - f" {preset_modes}" + f" {preset_modes}", + translation_placeholders={ + "preset_mode": preset_mode, + "preset_modes": preset_modes_str, + }, ) def set_direction(self, direction: str) -> None: @@ -267,6 +292,18 @@ class FanEntity(ToggleEntity): """Turn on the fan.""" raise NotImplementedError() + @final + async def async_handle_turn_on_service( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Validate and turn on the fan.""" + if preset_mode is not None: + self._valid_preset_mode_or_raise(preset_mode) + await self.async_turn_on(percentage, preset_mode, **kwargs) + async def async_turn_on( self, percentage: int | None = None, diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json index 674dcc2b92e..aab714d3e07 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -144,5 +144,10 @@ "reverse": "Reverse" } } + }, + "exceptions": { + "not_valid_preset_mode": { + "message": "Preset mode {preset_mode} is not valid, valid preset modes are: {preset_modes}." + } } } diff --git a/homeassistant/components/fastdotcom/__init__.py b/homeassistant/components/fastdotcom/__init__.py index 50e0cb04869..2fe5b3ccafc 100644 --- a/homeassistant/components/fastdotcom/__init__.py +++ b/homeassistant/components/fastdotcom/__init__.py @@ -8,23 +8,18 @@ from typing import Any from fastdotcom import fast_com import voluptuous as vol -from homeassistant.const import CONF_SCAN_INTERVAL, Platform +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType -DOMAIN = "fastdotcom" -DATA_UPDATED = f"{DOMAIN}_data_updated" +from .const import CONF_MANUAL, DATA_UPDATED, DEFAULT_INTERVAL, DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) -CONF_MANUAL = "manual" - -DEFAULT_INTERVAL = timedelta(hours=1) - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -40,38 +35,61 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup_platform(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Fast.com component. (deprecated).""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config[DOMAIN], + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Fast.com component.""" - conf = config[DOMAIN] data = hass.data[DOMAIN] = SpeedtestData(hass) - if not conf[CONF_MANUAL]: - async_track_time_interval(hass, data.update, conf[CONF_SCAN_INTERVAL]) + entry.async_on_unload( + async_track_time_interval(hass, data.update, timedelta(hours=DEFAULT_INTERVAL)) + ) + # Run an initial update to get a starting state + await data.update() - def update(service_call: ServiceCall | None = None) -> None: + async def update(service_call: ServiceCall | None = None) -> None: """Service call to manually update the data.""" - data.update() + await data.update() hass.services.async_register(DOMAIN, "speedtest", update) - hass.async_create_task( - async_load_platform(hass, Platform.SENSOR, DOMAIN, {}, config) + await hass.config_entries.async_forward_entry_setups( + entry, + PLATFORMS, ) return True +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Fast.com config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data.pop(DOMAIN) + return unload_ok + + class SpeedtestData: - """Get the latest data from fast.com.""" + """Get the latest data from Fast.com.""" def __init__(self, hass: HomeAssistant) -> None: """Initialize the data object.""" self.data: dict[str, Any] | None = None self._hass = hass - def update(self, now: datetime | None = None) -> None: + async def update(self, now: datetime | None = None) -> None: """Get the latest data from fast.com.""" - - _LOGGER.debug("Executing fast.com speedtest") - self.data = {"download": fast_com()} + _LOGGER.debug("Executing Fast.com speedtest") + fast_com_data = await self._hass.async_add_executor_job(fast_com) + self.data = {"download": fast_com_data} + _LOGGER.debug("Fast.com speedtest finished, with mbit/s: %s", fast_com_data) dispatcher_send(self._hass, DATA_UPDATED) diff --git a/homeassistant/components/fastdotcom/config_flow.py b/homeassistant/components/fastdotcom/config_flow.py new file mode 100644 index 00000000000..5ca35fd6802 --- /dev/null +++ b/homeassistant/components/fastdotcom/config_flow.py @@ -0,0 +1,50 @@ +"""Config flow for Fast.com integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigFlow +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import DEFAULT_NAME, DOMAIN + + +class FastdotcomConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Fast.com.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + if user_input is not None: + return self.async_create_entry(title=DEFAULT_NAME, data={}) + + return self.async_show_form(step_id="user") + + async def async_step_import( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by configuration file.""" + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Fast.com", + }, + ) + + return await self.async_step_user(user_input) diff --git a/homeassistant/components/fastdotcom/const.py b/homeassistant/components/fastdotcom/const.py new file mode 100644 index 00000000000..753825c4361 --- /dev/null +++ b/homeassistant/components/fastdotcom/const.py @@ -0,0 +1,15 @@ +"""Constants for the Fast.com integration.""" +import logging + +from homeassistant.const import Platform + +LOGGER = logging.getLogger(__package__) + +DOMAIN = "fastdotcom" +DATA_UPDATED = f"{DOMAIN}_data_updated" + +CONF_MANUAL = "manual" + +DEFAULT_NAME = "Fast.com" +DEFAULT_INTERVAL = 1 +PLATFORMS: list[Platform] = [Platform.SENSOR] diff --git a/homeassistant/components/fastdotcom/manifest.json b/homeassistant/components/fastdotcom/manifest.json index 73db5c0bf11..02fd3ade205 100644 --- a/homeassistant/components/fastdotcom/manifest.json +++ b/homeassistant/components/fastdotcom/manifest.json @@ -1,7 +1,8 @@ { "domain": "fastdotcom", "name": "Fast.com", - "codeowners": ["@rohankapoorcom"], + "codeowners": ["@rohankapoorcom", "@erwindouna"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fastdotcom", "iot_class": "cloud_polling", "loggers": ["fastdotcom"], diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index b20b0213835..939ab4a40e5 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -8,29 +8,28 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfDataRate from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DATA_UPDATED, DOMAIN as FASTDOTCOM_DOMAIN +from .const import DATA_UPDATED, DOMAIN -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Fast.com sensor.""" - async_add_entities([SpeedtestSensor(hass.data[FASTDOTCOM_DOMAIN])]) + async_add_entities([SpeedtestSensor(entry.entry_id, hass.data[DOMAIN])]) # pylint: disable-next=hass-invalid-inheritance # needs fixing class SpeedtestSensor(RestoreEntity, SensorEntity): - """Implementation of a FAst.com sensor.""" + """Implementation of a Fast.com sensor.""" _attr_name = "Fast.com Download" _attr_device_class = SensorDeviceClass.DATA_RATE @@ -39,9 +38,10 @@ class SpeedtestSensor(RestoreEntity, SensorEntity): _attr_icon = "mdi:speedometer" _attr_should_poll = False - def __init__(self, speedtest_data: dict[str, Any]) -> None: + def __init__(self, entry_id: str, speedtest_data: dict[str, Any]) -> None: """Initialize the sensor.""" self._speedtest_data = speedtest_data + self._attr_unique_id = entry_id async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" diff --git a/homeassistant/components/fastdotcom/strings.json b/homeassistant/components/fastdotcom/strings.json index 705eada9387..d647250b423 100644 --- a/homeassistant/components/fastdotcom/strings.json +++ b/homeassistant/components/fastdotcom/strings.json @@ -1,4 +1,14 @@ { + "config": { + "step": { + "user": { + "description": "Do you want to start the setup? The initial setup will take about 30-40 seconds." + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, "services": { "speedtest": { "name": "Speed test", diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index cdfa7f6a864..8b41c4f404f 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -186,12 +186,13 @@ class FibaroController: resolver = FibaroStateResolver(state) for event in resolver.get_events(): - fibaro_id = event.fibaro_id + # event does not always have a fibaro id, therefore it is + # essential that we first check for relevant event type if ( event.event_type.lower() == "centralsceneevent" - and fibaro_id in self._event_callbacks + and event.fibaro_id in self._event_callbacks ): - for callback in self._event_callbacks[fibaro_id]: + for callback in self._event_callbacks[event.fibaro_id]: callback(event) def register(self, device_id: int, callback: Any) -> None: diff --git a/homeassistant/components/fints/manifest.json b/homeassistant/components/fints/manifest.json index 821298434d9..063e612d35d 100644 --- a/homeassistant/components/fints/manifest.json +++ b/homeassistant/components/fints/manifest.json @@ -3,6 +3,7 @@ "name": "FinTS", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/fints", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["fints", "mt_940", "sepaxml"], "requirements": ["fints==3.1.0"] diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index 3b961054544..fafe1fcf2bf 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -168,14 +168,13 @@ class FinTsClient: if not account_information: return False - if not account_information["type"]: - # bank does not support account types, use value from config - if ( - account_information["iban"] in self.account_config - or account_information["account_number"] in self.account_config - ): - return True - elif 1 <= account_information["type"] <= 9: + if 1 <= account_information["type"] <= 9: + return True + + if ( + account_information["iban"] in self.account_config + or account_information["account_number"] in self.account_config + ): return True return False @@ -189,14 +188,13 @@ class FinTsClient: if not account_information: return False - if not account_information["type"]: - # bank does not support account types, use value from config - if ( - account_information["iban"] in self.holdings_config - or account_information["account_number"] in self.holdings_config - ): - return True - elif 30 <= account_information["type"] <= 39: + if 30 <= account_information["type"] <= 39: + return True + + if ( + account_information["iban"] in self.holdings_config + or account_information["account_number"] in self.holdings_config + ): return True return False @@ -215,7 +213,11 @@ class FinTsClient: holdings_accounts.append(account) else: - _LOGGER.warning("Could not determine type of account %s", account.iban) + _LOGGER.warning( + "Could not determine type of account %s from %s", + account.iban, + self.client.user_id, + ) return balance_accounts, holdings_accounts diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 1bac147306a..e2cfb3e3992 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -39,6 +39,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -515,10 +516,20 @@ SLEEP_START_TIME_12HR = FitbitSensorEntityDescription( FITBIT_RESOURCE_BATTERY = FitbitSensorEntityDescription( key="devices/battery", - name="Battery", + translation_key="battery", icon="mdi:battery", scope=FitbitScope.DEVICE, entity_category=EntityCategory.DIAGNOSTIC, + has_entity_name=True, +) +FITBIT_RESOURCE_BATTERY_LEVEL = FitbitSensorEntityDescription( + key="devices/battery_level", + translation_key="battery_level", + scope=FitbitScope.DEVICE, + entity_category=EntityCategory.DIAGNOSTIC, + has_entity_name=True, + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, ) FITBIT_RESOURCES_KEYS: Final[list[str]] = [ @@ -689,7 +700,7 @@ async def async_setup_entry( async_add_entities(entities) if data.device_coordinator and is_allowed_resource(FITBIT_RESOURCE_BATTERY): - async_add_entities( + battery_entities: list[SensorEntity] = [ FitbitBatterySensor( data.device_coordinator, user_profile.encoded_id, @@ -698,7 +709,17 @@ async def async_setup_entry( enable_default_override=is_explicit_enable(FITBIT_RESOURCE_BATTERY), ) for device in data.device_coordinator.data.values() + ] + battery_entities.extend( + FitbitBatteryLevelSensor( + data.device_coordinator, + user_profile.encoded_id, + FITBIT_RESOURCE_BATTERY_LEVEL, + device=device, + ) + for device in data.device_coordinator.data.values() ) + async_add_entities(battery_entities) class FitbitSensor(SensorEntity): @@ -753,8 +774,8 @@ class FitbitSensor(SensorEntity): self.async_schedule_update_ha_state(force_refresh=True) -class FitbitBatterySensor(CoordinatorEntity, SensorEntity): - """Implementation of a Fitbit sensor.""" +class FitbitBatterySensor(CoordinatorEntity[FitbitDeviceCoordinator], SensorEntity): + """Implementation of a Fitbit battery sensor.""" entity_description: FitbitSensorEntityDescription _attr_attribution = ATTRIBUTION @@ -771,10 +792,12 @@ class FitbitBatterySensor(CoordinatorEntity, SensorEntity): super().__init__(coordinator) self.entity_description = description self.device = device - self._attr_unique_id = f"{user_profile_id}_{description.key}" - if device is not None: - self._attr_name = f"{device.device_version} Battery" - self._attr_unique_id = f"{self._attr_unique_id}_{device.id}" + self._attr_unique_id = f"{user_profile_id}_{description.key}_{device.id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{user_profile_id}_{device.id}")}, + name=device.device_version, + model=device.device_version, + ) if enable_default_override: self._attr_entity_registry_enabled_default = True @@ -805,3 +828,42 @@ class FitbitBatterySensor(CoordinatorEntity, SensorEntity): self.device = self.coordinator.data[self.device.id] self._attr_native_value = self.device.battery self.async_write_ha_state() + + +class FitbitBatteryLevelSensor( + CoordinatorEntity[FitbitDeviceCoordinator], SensorEntity +): + """Implementation of a Fitbit battery level sensor.""" + + entity_description: FitbitSensorEntityDescription + _attr_attribution = ATTRIBUTION + + def __init__( + self, + coordinator: FitbitDeviceCoordinator, + user_profile_id: str, + description: FitbitSensorEntityDescription, + device: FitbitDevice, + ) -> None: + """Initialize the Fitbit sensor.""" + super().__init__(coordinator) + self.entity_description = description + self.device = device + self._attr_unique_id = f"{user_profile_id}_{description.key}_{device.id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{user_profile_id}_{device.id}")}, + name=device.device_version, + model=device.device_version, + ) + + async def async_added_to_hass(self) -> None: + """When entity is added to hass update state from existing coordinator data.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.device = self.coordinator.data[self.device.id] + self._attr_native_value = self.device.battery_level + self.async_write_ha_state() diff --git a/homeassistant/components/fitbit/strings.json b/homeassistant/components/fitbit/strings.json index 889b56f1bbd..e1ca1b01f7a 100644 --- a/homeassistant/components/fitbit/strings.json +++ b/homeassistant/components/fitbit/strings.json @@ -22,12 +22,25 @@ "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "wrong_account": "The user credentials provided do not match this Fitbit account." + "wrong_account": "The user credentials provided do not match this Fitbit account.", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } }, + "entity": { + "sensor": { + "battery": { + "name": "Battery" + }, + "battery_level": { + "name": "Battery level" + } + } + }, "issues": { "deprecated_yaml_no_import": { "title": "Fitbit YAML configuration is being removed", diff --git a/homeassistant/components/fivem/strings.json b/homeassistant/components/fivem/strings.json index 2ffb401f8c0..abdef61fb28 100644 --- a/homeassistant/components/fivem/strings.json +++ b/homeassistant/components/fivem/strings.json @@ -6,6 +6,9 @@ "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your FiveM server." } } }, diff --git a/homeassistant/components/fjaraskupan/fan.py b/homeassistant/components/fjaraskupan/fan.py index 142694a6bfb..ee989bb2ee0 100644 --- a/homeassistant/components/fjaraskupan/fan.py +++ b/homeassistant/components/fjaraskupan/fan.py @@ -131,11 +131,9 @@ class Fan(CoordinatorEntity[FjaraskupanCoordinator], FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - if command := PRESET_TO_COMMAND.get(preset_mode): - async with self.coordinator.async_connect_and_update() as device: - await device.send_command(command) - else: - raise UnsupportedPreset(f"The preset {preset_mode} is unsupported") + command = PRESET_TO_COMMAND[preset_mode] + async with self.coordinator.async_connect_and_update() as device: + await device.send_command(command) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" diff --git a/homeassistant/components/flo/strings.json b/homeassistant/components/flo/strings.json index 627f562be7e..3444911fbd4 100644 --- a/homeassistant/components/flo/strings.json +++ b/homeassistant/components/flo/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Flo device." } } }, diff --git a/homeassistant/components/flume/__init__.py b/homeassistant/components/flume/__init__.py index 9a96233e6a9..a5911af3c8f 100644 --- a/homeassistant/components/flume/__init__.py +++ b/homeassistant/components/flume/__init__.py @@ -16,6 +16,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.selector import ConfigEntrySelector @@ -88,7 +89,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - await async_setup_service(hass) + setup_service(hass) return True @@ -105,10 +106,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def async_setup_service(hass: HomeAssistant) -> None: +def setup_service(hass: HomeAssistant) -> None: """Add the services for the flume integration.""" - async def list_notifications(call: ServiceCall) -> ServiceResponse: + @callback + def list_notifications(call: ServiceCall) -> ServiceResponse: """Return the user notifications.""" entry_id: str = call.data[CONF_CONFIG_ENTRY] entry: ConfigEntry | None = hass.config_entries.async_get_entry(entry_id) diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json index 35964ee4546..de22006b274 100644 --- a/homeassistant/components/foscam/strings.json +++ b/homeassistant/components/foscam/strings.json @@ -9,6 +9,9 @@ "password": "[%key:common::config_flow::data::password%]", "rtsp_port": "RTSP port", "stream": "Stream" + }, + "data_description": { + "host": "The hostname or IP address of your Foscam camera." } } }, diff --git a/homeassistant/components/freebox/alarm_control_panel.py b/homeassistant/components/freebox/alarm_control_panel.py new file mode 100644 index 00000000000..be3d88cf5b4 --- /dev/null +++ b/homeassistant/components/freebox/alarm_control_panel.py @@ -0,0 +1,105 @@ +"""Support for Freebox alarms.""" +from typing import Any + +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMING, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, FreeboxHomeCategory +from .home_base import FreeboxHomeEntity +from .router import FreeboxRouter + +FREEBOX_TO_STATUS = { + "alarm1_arming": STATE_ALARM_ARMING, + "alarm2_arming": STATE_ALARM_ARMING, + "alarm1_armed": STATE_ALARM_ARMED_AWAY, + "alarm2_armed": STATE_ALARM_ARMED_HOME, + "alarm1_alert_timer": STATE_ALARM_TRIGGERED, + "alarm2_alert_timer": STATE_ALARM_TRIGGERED, + "alert": STATE_ALARM_TRIGGERED, + "idle": STATE_ALARM_DISARMED, +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up alarm panel.""" + router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] + + alarm_entities: list[AlarmControlPanelEntity] = [] + + for node in router.home_devices.values(): + if node["category"] == FreeboxHomeCategory.ALARM: + alarm_entities.append(FreeboxAlarm(hass, router, node)) + + if alarm_entities: + async_add_entities(alarm_entities, True) + + +class FreeboxAlarm(FreeboxHomeEntity, AlarmControlPanelEntity): + """Representation of a Freebox alarm.""" + + def __init__( + self, hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any] + ) -> None: + """Initialize an alarm.""" + super().__init__(hass, router, node) + + # Commands + self._command_trigger = self.get_command_id( + node["type"]["endpoints"], "slot", "trigger" + ) + self._command_arm_away = self.get_command_id( + node["type"]["endpoints"], "slot", "alarm1" + ) + self._command_arm_home = self.get_command_id( + node["type"]["endpoints"], "slot", "alarm2" + ) + self._command_disarm = self.get_command_id( + node["type"]["endpoints"], "slot", "off" + ) + self._command_state = self.get_command_id( + node["type"]["endpoints"], "signal", "state" + ) + + self._attr_supported_features = ( + AlarmControlPanelEntityFeature.ARM_AWAY + | (AlarmControlPanelEntityFeature.ARM_HOME if self._command_arm_home else 0) + | AlarmControlPanelEntityFeature.TRIGGER + ) + + async def async_alarm_disarm(self, code: str | None = None) -> None: + """Send disarm command.""" + await self.set_home_endpoint_value(self._command_disarm) + + async def async_alarm_arm_away(self, code: str | None = None) -> None: + """Send arm away command.""" + await self.set_home_endpoint_value(self._command_arm_away) + + async def async_alarm_arm_home(self, code: str | None = None) -> None: + """Send arm home command.""" + await self.set_home_endpoint_value(self._command_arm_home) + + async def async_alarm_trigger(self, code: str | None = None) -> None: + """Send alarm trigger command.""" + await self.set_home_endpoint_value(self._command_trigger) + + async def async_update(self) -> None: + """Update state.""" + state: str | None = await self.get_home_endpoint_value(self._command_state) + if state: + self._attr_state = FREEBOX_TO_STATUS.get(state) + else: + self._attr_state = None diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py index 0c3450d13b6..f74f6f49ebf 100644 --- a/homeassistant/components/freebox/const.py +++ b/homeassistant/components/freebox/const.py @@ -18,6 +18,7 @@ APP_DESC = { API_VERSION = "v6" PLATFORMS = [ + Platform.ALARM_CONTROL_PANEL, Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR, @@ -84,6 +85,7 @@ CATEGORY_TO_MODEL = { } HOME_COMPATIBLE_CATEGORIES = [ + FreeboxHomeCategory.ALARM, FreeboxHomeCategory.CAMERA, FreeboxHomeCategory.DWS, FreeboxHomeCategory.IOHOME, diff --git a/homeassistant/components/freebox/home_base.py b/homeassistant/components/freebox/home_base.py index 2cc1a5fcfe3..022528e5ea7 100644 --- a/homeassistant/components/freebox/home_base.py +++ b/homeassistant/components/freebox/home_base.py @@ -131,13 +131,14 @@ class FreeboxHomeEntity(Entity): def get_value(self, ep_type: str, name: str): """Get the value.""" node = next( - filter( - lambda x: (x["name"] == name and x["ep_type"] == ep_type), - self._node["show_endpoints"], + ( + endpoint + for endpoint in self._node["show_endpoints"] + if endpoint["name"] == name and endpoint["ep_type"] == ep_type ), None, ) - if not node: + if node is None: _LOGGER.warning( "The Freebox Home device has no node value for: %s/%s", ep_type, name ) diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index 6a73624a776..765761c43f2 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -4,9 +4,11 @@ from __future__ import annotations from collections.abc import Mapping from contextlib import suppress from datetime import datetime +import json import logging import os from pathlib import Path +import re from typing import Any from freebox_api import Freepybox @@ -36,6 +38,20 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +def is_json(json_str): + """Validate if a String is a JSON value or not.""" + try: + json.loads(json_str) + return True + except (ValueError, TypeError) as err: + _LOGGER.error( + "Failed to parse JSON '%s', error '%s'", + json_str, + err, + ) + return False + + async def get_api(hass: HomeAssistant, host: str) -> Freepybox: """Get the Freebox API.""" freebox_path = Store(hass, STORAGE_VERSION, STORAGE_KEY).path @@ -69,6 +85,7 @@ class FreeboxRouter: self._sw_v: str = freebox_config["firmware_version"] self._attrs: dict[str, Any] = {} + self.supports_hosts = True self.devices: dict[str, dict[str, Any]] = {} self.disks: dict[int, dict[str, Any]] = {} self.supports_raid = True @@ -89,7 +106,32 @@ class FreeboxRouter: async def update_device_trackers(self) -> None: """Update Freebox devices.""" new_device = False - fbx_devices: list[dict[str, Any]] = await self._api.lan.get_hosts_list() + + fbx_devices: list[dict[str, Any]] = [] + + # Access to Host list not available in bridge mode, API return error_code 'nodev' + if self.supports_hosts: + try: + fbx_devices = await self._api.lan.get_hosts_list() + except HttpRequestError as err: + if ( + ( + matcher := re.search( + r"Request failed \(APIResponse: (.+)\)", str(err) + ) + ) + and is_json(json_str := matcher.group(1)) + and (json_resp := json.loads(json_str)).get("error_code") == "nodev" + ): + # No need to retry, Host list not available + self.supports_hosts = False + _LOGGER.debug( + "Host list is not available using bridge mode (%s)", + json_resp.get("msg"), + ) + + else: + raise err # Adds the Freebox itself fbx_devices.append( diff --git a/homeassistant/components/freebox/strings.json b/homeassistant/components/freebox/strings.json index 5c4143b4562..eaa56a38da1 100644 --- a/homeassistant/components/freebox/strings.json +++ b/homeassistant/components/freebox/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Freebox router." } }, "link": { diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 7cbb10a236b..5eed2f59fc4 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -26,6 +26,9 @@ "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your FRITZ!Box router." } } }, diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index dc56bc0473e..2460635351e 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -14,12 +14,11 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import FritzBoxDeviceEntity -from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN -from .coordinator import FritzboxDataUpdateCoordinator +from .common import get_coordinator from .model import FritzEntityDescriptionMixinBase @@ -49,7 +48,7 @@ BINARY_SENSOR_TYPES: Final[tuple[FritzBinarySensorEntityDescription, ...]] = ( key="lock", translation_key="lock", device_class=BinarySensorDeviceClass.LOCK, - entity_category=EntityCategory.CONFIG, + entity_category=EntityCategory.DIAGNOSTIC, suitable=lambda device: device.lock is not None, is_on=lambda device: not device.lock, ), @@ -57,7 +56,7 @@ BINARY_SENSOR_TYPES: Final[tuple[FritzBinarySensorEntityDescription, ...]] = ( key="device_lock", translation_key="device_lock", device_class=BinarySensorDeviceClass.LOCK, - entity_category=EntityCategory.CONFIG, + entity_category=EntityCategory.DIAGNOSTIC, suitable=lambda device: device.device_lock is not None, is_on=lambda device: not device.device_lock, ), @@ -68,18 +67,23 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome binary sensor from ConfigEntry.""" - coordinator: FritzboxDataUpdateCoordinator = hass.data[FRITZBOX_DOMAIN][ - entry.entry_id - ][CONF_COORDINATOR] + coordinator = get_coordinator(hass, entry.entry_id) - async_add_entities( - [ + @callback + def _add_entities() -> None: + """Add devices.""" + if not coordinator.new_devices: + return + async_add_entities( FritzboxBinarySensor(coordinator, ain, description) - for ain, device in coordinator.data.devices.items() + for ain in coordinator.new_devices for description in BINARY_SENSOR_TYPES - if description.suitable(device) - ] - ) + if description.suitable(coordinator.data.devices[ain]) + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class FritzboxBinarySensor(FritzBoxDeviceEntity, BinarySensorEntity): diff --git a/homeassistant/components/fritzbox/button.py b/homeassistant/components/fritzbox/button.py index cc5457fb8a2..732c41bfb7d 100644 --- a/homeassistant/components/fritzbox/button.py +++ b/homeassistant/components/fritzbox/button.py @@ -3,25 +3,33 @@ from pyfritzhome.devicetypes import FritzhomeTemplate from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FritzboxDataUpdateCoordinator, FritzBoxEntity -from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN +from . import FritzBoxEntity +from .common import get_coordinator +from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome template from ConfigEntry.""" - coordinator: FritzboxDataUpdateCoordinator = hass.data[FRITZBOX_DOMAIN][ - entry.entry_id - ][CONF_COORDINATOR] + coordinator = get_coordinator(hass, entry.entry_id) - async_add_entities( - [FritzBoxTemplate(coordinator, ain) for ain in coordinator.data.templates] - ) + @callback + def _add_entities() -> None: + """Add templates.""" + if not coordinator.new_templates: + return + async_add_entities( + FritzBoxTemplate(coordinator, ain) for ain in coordinator.new_templates + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class FritzBoxTemplate(FritzBoxEntity, ButtonEntity): @@ -37,7 +45,7 @@ class FritzBoxTemplate(FritzBoxEntity, ButtonEntity): """Return device specific attributes.""" return DeviceInfo( name=self.data.name, - identifiers={(FRITZBOX_DOMAIN, self.ain)}, + identifiers={(DOMAIN, self.ain)}, configuration_url=self.coordinator.configuration_url, manufacturer="AVM", model="SmartHome Template", diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 7c846789637..70359d9b2af 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -18,17 +18,16 @@ from homeassistant.const import ( PRECISION_HALVES, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FritzboxDataUpdateCoordinator, FritzBoxDeviceEntity +from . import FritzBoxDeviceEntity +from .common import get_coordinator from .const import ( ATTR_STATE_BATTERY_LOW, ATTR_STATE_HOLIDAY_MODE, ATTR_STATE_SUMMER_MODE, ATTR_STATE_WINDOW_OPEN, - CONF_COORDINATOR, - DOMAIN as FRITZBOX_DOMAIN, ) from .model import ClimateExtraAttributes @@ -50,17 +49,22 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome thermostat from ConfigEntry.""" - coordinator: FritzboxDataUpdateCoordinator = hass.data[FRITZBOX_DOMAIN][ - entry.entry_id - ][CONF_COORDINATOR] + coordinator = get_coordinator(hass, entry.entry_id) - async_add_entities( - [ + @callback + def _add_entities() -> None: + """Add devices.""" + if not coordinator.new_devices: + return + async_add_entities( FritzboxThermostat(coordinator, ain) - for ain, device in coordinator.data.devices.items() - if device.has_thermostat - ] - ) + for ain in coordinator.new_devices + if coordinator.data.devices[ain].has_thermostat + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): diff --git a/homeassistant/components/fritzbox/common.py b/homeassistant/components/fritzbox/common.py new file mode 100644 index 00000000000..ab87a51f9ce --- /dev/null +++ b/homeassistant/components/fritzbox/common.py @@ -0,0 +1,16 @@ +"""Common functions for fritzbox integration.""" + +from homeassistant.core import HomeAssistant + +from .const import CONF_COORDINATOR, DOMAIN +from .coordinator import FritzboxDataUpdateCoordinator + + +def get_coordinator( + hass: HomeAssistant, config_entry_id: str +) -> FritzboxDataUpdateCoordinator: + """Get coordinator for given config entry id.""" + coordinator: FritzboxDataUpdateCoordinator = hass.data[DOMAIN][config_entry_id][ + CONF_COORDINATOR + ] + return coordinator diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index 194825e602f..f6d210e367a 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -37,6 +37,8 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat self.fritz: Fritzhome = hass.data[DOMAIN][self.entry.entry_id][CONF_CONNECTIONS] self.configuration_url = self.fritz.get_prefixed_host() self.has_templates = has_templates + self.new_devices: set[str] = set() + self.new_templates: set[str] = set() super().__init__( hass, @@ -45,6 +47,8 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat update_interval=timedelta(seconds=30), ) + self.data = FritzboxCoordinatorData({}, {}) + def _update_fritz_devices(self) -> FritzboxCoordinatorData: """Update all fritzbox device data.""" try: @@ -87,6 +91,9 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat for template in templates: template_data[template.ain] = template + self.new_devices = device_data.keys() - self.data.devices.keys() + self.new_templates = template_data.keys() - self.data.templates.keys() + return FritzboxCoordinatorData(devices=device_data, templates=template_data) async def _async_update_data(self) -> FritzboxCoordinatorData: diff --git a/homeassistant/components/fritzbox/cover.py b/homeassistant/components/fritzbox/cover.py index df3b1562f9b..7d27356fdf9 100644 --- a/homeassistant/components/fritzbox/cover.py +++ b/homeassistant/components/fritzbox/cover.py @@ -10,26 +10,33 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FritzboxDataUpdateCoordinator, FritzBoxDeviceEntity -from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN +from . import FritzBoxDeviceEntity +from .common import get_coordinator async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome cover from ConfigEntry.""" - coordinator: FritzboxDataUpdateCoordinator = hass.data[FRITZBOX_DOMAIN][ - entry.entry_id - ][CONF_COORDINATOR] + coordinator = get_coordinator(hass, entry.entry_id) - async_add_entities( - FritzboxCover(coordinator, ain) - for ain, device in coordinator.data.devices.items() - if device.has_blind - ) + @callback + def _add_entities() -> None: + """Add devices.""" + if not coordinator.new_devices: + return + async_add_entities( + FritzboxCover(coordinator, ain) + for ain in coordinator.new_devices + if coordinator.data.devices[ain].has_blind + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class FritzboxCover(FritzBoxDeviceEntity, CoverEntity): diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index f83dd454592..d31ccd180c4 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -13,17 +13,12 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import FritzboxDataUpdateCoordinator, FritzBoxDeviceEntity -from .const import ( - COLOR_MODE, - COLOR_TEMP_MODE, - CONF_COORDINATOR, - DOMAIN as FRITZBOX_DOMAIN, - LOGGER, -) +from .common import get_coordinator +from .const import COLOR_MODE, COLOR_TEMP_MODE, LOGGER SUPPORTED_COLOR_MODES = {ColorMode.COLOR_TEMP, ColorMode.HS} @@ -32,31 +27,27 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome light from ConfigEntry.""" - entities: list[FritzboxLight] = [] - coordinator: FritzboxDataUpdateCoordinator = hass.data[FRITZBOX_DOMAIN][ - entry.entry_id - ][CONF_COORDINATOR] + coordinator = get_coordinator(hass, entry.entry_id) - for ain, device in coordinator.data.devices.items(): - if not device.has_lightbulb: - continue - - supported_color_temps = await hass.async_add_executor_job( - device.get_color_temps - ) - - supported_colors = await hass.async_add_executor_job(device.get_colors) - - entities.append( + @callback + def _add_entities() -> None: + """Add devices.""" + if not coordinator.new_devices: + return + async_add_entities( FritzboxLight( coordinator, ain, - supported_colors, - supported_color_temps, + device.get_colors(), + device.get_color_temps(), ) + for ain in coordinator.new_devices + if (device := coordinator.data.devices[ain]).has_lightbulb ) - async_add_entities(entities) + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class FritzboxLight(FritzBoxDeviceEntity, LightEntity): diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 013c1dfc7b5..1e5d7754934 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -25,13 +25,13 @@ from homeassistant.const import ( UnitOfPower, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utc_from_timestamp from . import FritzBoxDeviceEntity -from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN +from .common import get_coordinator from .model import FritzEntityDescriptionMixinBase @@ -212,16 +212,23 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome sensor from ConfigEntry.""" - coordinator = hass.data[FRITZBOX_DOMAIN][entry.entry_id][CONF_COORDINATOR] + coordinator = get_coordinator(hass, entry.entry_id) - async_add_entities( - [ + @callback + def _add_entities() -> None: + """Add devices.""" + if not coordinator.new_devices: + return + async_add_entities( FritzBoxSensor(coordinator, ain, description) - for ain, device in coordinator.data.devices.items() + for ain in coordinator.new_devices for description in SENSOR_TYPES - if description.suitable(device) - ] - ) + if description.suitable(coordinator.data.devices[ain]) + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class FritzBoxSensor(FritzBoxDeviceEntity, SensorEntity): diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json index d5607aa3090..f4d2fe3670e 100644 --- a/homeassistant/components/fritzbox/strings.json +++ b/homeassistant/components/fritzbox/strings.json @@ -8,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your FRITZ!Box router." } }, "confirm": { diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index 5eee3019633..617a5242c5b 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -5,28 +5,33 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FritzboxDataUpdateCoordinator, FritzBoxDeviceEntity -from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN +from . import FritzBoxDeviceEntity +from .common import get_coordinator async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome switch from ConfigEntry.""" - coordinator: FritzboxDataUpdateCoordinator = hass.data[FRITZBOX_DOMAIN][ - entry.entry_id - ][CONF_COORDINATOR] + coordinator = get_coordinator(hass, entry.entry_id) - async_add_entities( - [ + @callback + def _add_entities() -> None: + """Add devices.""" + if not coordinator.new_devices: + return + async_add_entities( FritzboxSwitch(coordinator, ain) - for ain, device in coordinator.data.devices.items() - if device.has_switch - ] - ) + for ain in coordinator.new_devices + if coordinator.data.devices[ain].has_switch + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity): diff --git a/homeassistant/components/fritzbox_callmonitor/strings.json b/homeassistant/components/fritzbox_callmonitor/strings.json index 89f049bfbe9..ac36942eec2 100644 --- a/homeassistant/components/fritzbox_callmonitor/strings.json +++ b/homeassistant/components/fritzbox_callmonitor/strings.json @@ -8,6 +8,9 @@ "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your FRITZ!Box router." } }, "phonebook": { diff --git a/homeassistant/components/fronius/const.py b/homeassistant/components/fronius/const.py index 4060731b21c..18f35de8336 100644 --- a/homeassistant/components/fronius/const.py +++ b/homeassistant/components/fronius/const.py @@ -1,7 +1,9 @@ """Constants for the Fronius integration.""" +from enum import StrEnum from typing import Final, NamedTuple, TypedDict from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.typing import StateType DOMAIN: Final = "fronius" @@ -25,3 +27,97 @@ class FroniusDeviceInfo(NamedTuple): device_info: DeviceInfo solar_net_id: SolarNetId unique_id: str + + +class InverterStatusCodeOption(StrEnum): + """Status codes for Fronius inverters.""" + + # these are keys for state translations - so snake_case is used + STARTUP = "startup" + RUNNING = "running" + STANDBY = "standby" + BOOTLOADING = "bootloading" + ERROR = "error" + IDLE = "idle" + READY = "ready" + SLEEPING = "sleeping" + UNKNOWN = "unknown" + INVALID = "invalid" + + +_INVERTER_STATUS_CODES: Final[dict[int, InverterStatusCodeOption]] = { + 0: InverterStatusCodeOption.STARTUP, + 1: InverterStatusCodeOption.STARTUP, + 2: InverterStatusCodeOption.STARTUP, + 3: InverterStatusCodeOption.STARTUP, + 4: InverterStatusCodeOption.STARTUP, + 5: InverterStatusCodeOption.STARTUP, + 6: InverterStatusCodeOption.STARTUP, + 7: InverterStatusCodeOption.RUNNING, + 8: InverterStatusCodeOption.STANDBY, + 9: InverterStatusCodeOption.BOOTLOADING, + 10: InverterStatusCodeOption.ERROR, + 11: InverterStatusCodeOption.IDLE, + 12: InverterStatusCodeOption.READY, + 13: InverterStatusCodeOption.SLEEPING, + 255: InverterStatusCodeOption.UNKNOWN, +} + + +def get_inverter_status_message(code: StateType) -> InverterStatusCodeOption: + """Return a status message for a given status code.""" + return _INVERTER_STATUS_CODES.get(code, InverterStatusCodeOption.INVALID) # type: ignore[arg-type] + + +class MeterLocationCodeOption(StrEnum): + """Meter location codes for Fronius meters.""" + + # these are keys for state translations - so snake_case is used + FEED_IN = "feed_in" + CONSUMPTION_PATH = "consumption_path" + GENERATOR = "external_generator" + EXT_BATTERY = "external_battery" + SUBLOAD = "subload" + + +def get_meter_location_description(code: StateType) -> MeterLocationCodeOption | None: + """Return a location_description for a given location code.""" + match int(code): # type: ignore[arg-type] + case 0: + return MeterLocationCodeOption.FEED_IN + case 1: + return MeterLocationCodeOption.CONSUMPTION_PATH + case 3: + return MeterLocationCodeOption.GENERATOR + case 4: + return MeterLocationCodeOption.EXT_BATTERY + case _ as _code if 256 <= _code <= 511: + return MeterLocationCodeOption.SUBLOAD + return None + + +class OhmPilotStateCodeOption(StrEnum): + """OhmPilot state codes for Fronius inverters.""" + + # these are keys for state translations - so snake_case is used + UP_AND_RUNNING = "up_and_running" + KEEP_MINIMUM_TEMPERATURE = "keep_minimum_temperature" + LEGIONELLA_PROTECTION = "legionella_protection" + CRITICAL_FAULT = "critical_fault" + FAULT = "fault" + BOOST_MODE = "boost_mode" + + +_OHMPILOT_STATE_CODES: Final[dict[int, OhmPilotStateCodeOption]] = { + 0: OhmPilotStateCodeOption.UP_AND_RUNNING, + 1: OhmPilotStateCodeOption.KEEP_MINIMUM_TEMPERATURE, + 2: OhmPilotStateCodeOption.LEGIONELLA_PROTECTION, + 3: OhmPilotStateCodeOption.CRITICAL_FAULT, + 4: OhmPilotStateCodeOption.FAULT, + 5: OhmPilotStateCodeOption.BOOST_MODE, +} + + +def get_ohmpilot_state_message(code: StateType) -> OhmPilotStateCodeOption | None: + """Return a status message for a given status code.""" + return _OHMPILOT_STATE_CODES.get(code) # type: ignore[arg-type] diff --git a/homeassistant/components/fronius/coordinator.py b/homeassistant/components/fronius/coordinator.py index 94fd5f256aa..fcf9ce0a389 100644 --- a/homeassistant/components/fronius/coordinator.py +++ b/homeassistant/components/fronius/coordinator.py @@ -49,8 +49,10 @@ class FroniusCoordinatorBase( """Set up the FroniusCoordinatorBase class.""" self._failed_update_count = 0 self.solar_net = solar_net - # unregistered_keys are used to create entities in platform module - self.unregistered_keys: dict[SolarNetId, set[str]] = {} + # unregistered_descriptors are used to create entities in platform module + self.unregistered_descriptors: dict[ + SolarNetId, list[FroniusSensorEntityDescription] + ] = {} super().__init__(*args, update_interval=self.default_interval, **kwargs) @abstractmethod @@ -73,11 +75,11 @@ class FroniusCoordinatorBase( self.update_interval = self.default_interval for solar_net_id in data: - if solar_net_id not in self.unregistered_keys: + if solar_net_id not in self.unregistered_descriptors: # id seen for the first time - self.unregistered_keys[solar_net_id] = { - desc.key for desc in self.valid_descriptions - } + self.unregistered_descriptors[ + solar_net_id + ] = self.valid_descriptions.copy() return data @callback @@ -92,22 +94,34 @@ class FroniusCoordinatorBase( """ @callback - def _add_entities_for_unregistered_keys() -> None: + def _add_entities_for_unregistered_descriptors() -> None: """Add entities for keys seen for the first time.""" - new_entities: list = [] + new_entities: list[_FroniusEntityT] = [] for solar_net_id, device_data in self.data.items(): - for key in self.unregistered_keys[solar_net_id].intersection( - device_data - ): - if device_data[key]["value"] is None: + remaining_unregistered_descriptors = [] + for description in self.unregistered_descriptors[solar_net_id]: + key = description.response_key or description.key + if key not in device_data: + remaining_unregistered_descriptors.append(description) continue - new_entities.append(entity_constructor(self, key, solar_net_id)) - self.unregistered_keys[solar_net_id].remove(key) + if device_data[key]["value"] is None: + remaining_unregistered_descriptors.append(description) + continue + new_entities.append( + entity_constructor( + coordinator=self, + description=description, + solar_net_id=solar_net_id, + ) + ) + self.unregistered_descriptors[ + solar_net_id + ] = remaining_unregistered_descriptors async_add_entities(new_entities) - _add_entities_for_unregistered_keys() + _add_entities_for_unregistered_descriptors() self.solar_net.cleanup_callbacks.append( - self.async_add_listener(_add_entities_for_unregistered_keys) + self.async_add_listener(_add_entities_for_unregistered_descriptors) ) diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index f11855ce7e2..f058a25a044 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -1,6 +1,7 @@ """Support for Fronius devices.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final @@ -30,7 +31,16 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, SOLAR_NET_DISCOVERY_NEW +from .const import ( + DOMAIN, + SOLAR_NET_DISCOVERY_NEW, + InverterStatusCodeOption, + MeterLocationCodeOption, + OhmPilotStateCodeOption, + get_inverter_status_message, + get_meter_location_description, + get_ohmpilot_state_message, +) if TYPE_CHECKING: from . import FroniusSolarNet @@ -102,6 +112,8 @@ class FroniusSensorEntityDescription(SensorEntityDescription): # Gen24 devices may report 0 for total energy while doing firmware updates. # Handling such values shall mitigate spikes in delta calculations. invalid_when_falsy: bool = False + response_key: str | None = None + value_fn: Callable[[StateType], StateType] | None = None INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ @@ -198,6 +210,15 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ FroniusSensorEntityDescription( key="status_code", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + FroniusSensorEntityDescription( + key="status_message", + response_key="status_code", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=[opt.value for opt in InverterStatusCodeOption], + value_fn=get_inverter_status_message, ), FroniusSensorEntityDescription( key="led_state", @@ -306,6 +327,15 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ FroniusSensorEntityDescription( key="meter_location", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=int, # type: ignore[arg-type] + ), + FroniusSensorEntityDescription( + key="meter_location_description", + response_key="meter_location", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=[opt.value for opt in MeterLocationCodeOption], + value_fn=get_meter_location_description, ), FroniusSensorEntityDescription( key="power_apparent_phase_1", @@ -495,7 +525,11 @@ OHMPILOT_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ), FroniusSensorEntityDescription( key="state_message", + response_key="state_code", entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=[opt.value for opt in OhmPilotStateCodeOption], + value_fn=get_ohmpilot_state_message, ), ] @@ -630,24 +664,22 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn """Defines a Fronius coordinator entity.""" entity_description: FroniusSensorEntityDescription - entity_descriptions: list[FroniusSensorEntityDescription] _attr_has_entity_name = True def __init__( self, coordinator: FroniusCoordinatorBase, - key: str, + description: FroniusSensorEntityDescription, solar_net_id: str, ) -> None: """Set up an individual Fronius meter sensor.""" super().__init__(coordinator) - self.entity_description = next( - desc for desc in self.entity_descriptions if desc.key == key - ) + self.entity_description = description + self.response_key = description.response_key or description.key self.solar_net_id = solar_net_id self._attr_native_value = self._get_entity_value() - self._attr_translation_key = self.entity_description.key + self._attr_translation_key = description.key def _device_data(self) -> dict[str, Any]: """Extract information for SolarNet device from coordinator data.""" @@ -655,13 +687,13 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn def _get_entity_value(self) -> Any: """Extract entity value from coordinator. Raises KeyError if not included in latest update.""" - new_value = self.coordinator.data[self.solar_net_id][ - self.entity_description.key - ]["value"] + new_value = self.coordinator.data[self.solar_net_id][self.response_key]["value"] if new_value is None: return self.entity_description.default_value if self.entity_description.invalid_when_falsy and not new_value: return None + if self.entity_description.value_fn is not None: + return self.entity_description.value_fn(new_value) if isinstance(new_value, float): return round(new_value, 4) return new_value @@ -681,54 +713,54 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn class InverterSensor(_FroniusSensorEntity): """Defines a Fronius inverter device sensor entity.""" - entity_descriptions = INVERTER_ENTITY_DESCRIPTIONS - def __init__( self, coordinator: FroniusInverterUpdateCoordinator, - key: str, + description: FroniusSensorEntityDescription, solar_net_id: str, ) -> None: """Set up an individual Fronius inverter sensor.""" - super().__init__(coordinator, key, solar_net_id) + super().__init__(coordinator, description, solar_net_id) # device_info created in __init__ from a `GetInverterInfo` request self._attr_device_info = coordinator.inverter_info.device_info - self._attr_unique_id = f"{coordinator.inverter_info.unique_id}-{key}" + self._attr_unique_id = ( + f"{coordinator.inverter_info.unique_id}-{description.key}" + ) class LoggerSensor(_FroniusSensorEntity): """Defines a Fronius logger device sensor entity.""" - entity_descriptions = LOGGER_ENTITY_DESCRIPTIONS - def __init__( self, coordinator: FroniusLoggerUpdateCoordinator, - key: str, + description: FroniusSensorEntityDescription, solar_net_id: str, ) -> None: """Set up an individual Fronius meter sensor.""" - super().__init__(coordinator, key, solar_net_id) + super().__init__(coordinator, description, solar_net_id) logger_data = self._device_data() # Logger device is already created in FroniusSolarNet._create_solar_net_device self._attr_device_info = coordinator.solar_net.system_device_info - self._attr_native_unit_of_measurement = logger_data[key].get("unit") - self._attr_unique_id = f'{logger_data["unique_identifier"]["value"]}-{key}' + self._attr_native_unit_of_measurement = logger_data[self.response_key].get( + "unit" + ) + self._attr_unique_id = ( + f'{logger_data["unique_identifier"]["value"]}-{description.key}' + ) class MeterSensor(_FroniusSensorEntity): """Defines a Fronius meter device sensor entity.""" - entity_descriptions = METER_ENTITY_DESCRIPTIONS - def __init__( self, coordinator: FroniusMeterUpdateCoordinator, - key: str, + description: FroniusSensorEntityDescription, solar_net_id: str, ) -> None: """Set up an individual Fronius meter sensor.""" - super().__init__(coordinator, key, solar_net_id) + super().__init__(coordinator, description, solar_net_id) meter_data = self._device_data() # S0 meters connected directly to inverters respond "n.a." as serial number # `model` contains the inverter id: "S0 Meter at inverter 1" @@ -745,22 +777,20 @@ class MeterSensor(_FroniusSensorEntity): name=meter_data["model"]["value"], via_device=(DOMAIN, coordinator.solar_net.solar_net_device_id), ) - self._attr_unique_id = f"{meter_uid}-{key}" + self._attr_unique_id = f"{meter_uid}-{description.key}" class OhmpilotSensor(_FroniusSensorEntity): """Defines a Fronius Ohmpilot sensor entity.""" - entity_descriptions = OHMPILOT_ENTITY_DESCRIPTIONS - def __init__( self, coordinator: FroniusOhmpilotUpdateCoordinator, - key: str, + description: FroniusSensorEntityDescription, solar_net_id: str, ) -> None: """Set up an individual Fronius meter sensor.""" - super().__init__(coordinator, key, solar_net_id) + super().__init__(coordinator, description, solar_net_id) device_data = self._device_data() self._attr_device_info = DeviceInfo( @@ -771,45 +801,41 @@ class OhmpilotSensor(_FroniusSensorEntity): sw_version=device_data["software"]["value"], via_device=(DOMAIN, coordinator.solar_net.solar_net_device_id), ) - self._attr_unique_id = f'{device_data["serial"]["value"]}-{key}' + self._attr_unique_id = f'{device_data["serial"]["value"]}-{description.key}' class PowerFlowSensor(_FroniusSensorEntity): """Defines a Fronius power flow sensor entity.""" - entity_descriptions = POWER_FLOW_ENTITY_DESCRIPTIONS - def __init__( self, coordinator: FroniusPowerFlowUpdateCoordinator, - key: str, + description: FroniusSensorEntityDescription, solar_net_id: str, ) -> None: """Set up an individual Fronius power flow sensor.""" - super().__init__(coordinator, key, solar_net_id) + super().__init__(coordinator, description, solar_net_id) # SolarNet device is already created in FroniusSolarNet._create_solar_net_device self._attr_device_info = coordinator.solar_net.system_device_info self._attr_unique_id = ( - f"{coordinator.solar_net.solar_net_device_id}-power_flow-{key}" + f"{coordinator.solar_net.solar_net_device_id}-power_flow-{description.key}" ) class StorageSensor(_FroniusSensorEntity): """Defines a Fronius storage device sensor entity.""" - entity_descriptions = STORAGE_ENTITY_DESCRIPTIONS - def __init__( self, coordinator: FroniusStorageUpdateCoordinator, - key: str, + description: FroniusSensorEntityDescription, solar_net_id: str, ) -> None: """Set up an individual Fronius storage sensor.""" - super().__init__(coordinator, key, solar_net_id) + super().__init__(coordinator, description, solar_net_id) storage_data = self._device_data() - self._attr_unique_id = f'{storage_data["serial"]["value"]}-{key}' + self._attr_unique_id = f'{storage_data["serial"]["value"]}-{description.key}' self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, storage_data["serial"]["value"])}, manufacturer=storage_data["manufacturer"]["value"], diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json index 4a0f96ed8e6..de066704644 100644 --- a/homeassistant/components/fronius/strings.json +++ b/homeassistant/components/fronius/strings.json @@ -66,6 +66,21 @@ "status_code": { "name": "Status code" }, + "status_message": { + "name": "Status message", + "state": { + "startup": "Startup", + "running": "Running", + "standby": "Standby", + "bootloading": "Bootloading", + "error": "Error", + "idle": "Idle", + "ready": "Ready", + "sleeping": "Sleeping", + "unknown": "Unknown", + "invalid": "Invalid" + } + }, "led_state": { "name": "LED state" }, @@ -114,6 +129,16 @@ "meter_location": { "name": "Meter location" }, + "meter_location_description": { + "name": "Meter location description", + "state": { + "feed_in": "Grid interconnection point", + "consumption_path": "Consumption path", + "external_generator": "External generator", + "external_battery": "External battery", + "subload": "Subload" + } + }, "power_apparent_phase_1": { "name": "Apparent power phase 1" }, @@ -193,7 +218,15 @@ "name": "State code" }, "state_message": { - "name": "State message" + "name": "State message", + "state": { + "up_and_running": "Up and running", + "keep_minimum_temperature": "Keep minimum temperature", + "legionella_protection": "Legionella protection", + "critical_fault": "Critical fault", + "fault": "Fault", + "boost_mode": "Boost mode" + } }, "meter_mode": { "name": "Meter mode" diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 2ec991750f0..14892c35aac 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -385,9 +385,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if os.path.isdir(local): hass.http.register_static_path("/local", local, not is_dev) - # Can be removed in 2023 - hass.http.register_redirect("/config/server_control", "/developer-tools/yaml") - # Shopping list panel was replaced by todo panel in 2023.11 hass.http.register_redirect("/shopping-list", "/todo") diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 469deab23e1..af2ea6f9149 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==20231030.2"] + "requirements": ["home-assistant-frontend==20231206.0"] } diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index a10c3f535a1..03d9f28c016 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -5,10 +5,13 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Frontier Silicon device." } }, "device_config": { - "title": "Device Configuration", + "title": "Device configuration", "description": "The pin can be found via 'MENU button > Main Menu > System setting > Network > NetRemote PIN setup'", "data": { "pin": "[%key:common::config_flow::data::pin%]" diff --git a/homeassistant/components/fully_kiosk/config_flow.py b/homeassistant/components/fully_kiosk/config_flow.py index 7d744214d93..4f9dadd6901 100644 --- a/homeassistant/components/fully_kiosk/config_flow.py +++ b/homeassistant/components/fully_kiosk/config_flow.py @@ -12,7 +12,13 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components.dhcp import DhcpServiceInfo -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_SSL, + CONF_VERIFY_SSL, +) from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac @@ -31,13 +37,19 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._discovered_device_info: dict[str, Any] = {} async def _create_entry( - self, host: str, user_input: dict[str, Any], errors: dict[str, str] + self, + host: str, + user_input: dict[str, Any], + errors: dict[str, str], + description_placeholders: dict[str, str] | Any = None, ) -> FlowResult | None: fully = FullyKiosk( async_get_clientsession(self.hass), host, DEFAULT_PORT, user_input[CONF_PASSWORD], + use_ssl=user_input[CONF_SSL], + verify_ssl=user_input[CONF_VERIFY_SSL], ) try: @@ -50,10 +62,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) as error: LOGGER.debug(error.args, exc_info=True) errors["base"] = "cannot_connect" + description_placeholders["error_detail"] = str(error.args) return None except Exception as error: # pylint: disable=broad-except LOGGER.exception("Unexpected exception: %s", error) errors["base"] = "unknown" + description_placeholders["error_detail"] = str(error.args) return None await self.async_set_unique_id(device_info["deviceID"], raise_on_progress=False) @@ -64,6 +78,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONF_HOST: host, CONF_PASSWORD: user_input[CONF_PASSWORD], CONF_MAC: format_mac(device_info["Mac"]), + CONF_SSL: user_input[CONF_SSL], + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], }, ) @@ -72,8 +88,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle the initial step.""" errors: dict[str, str] = {} + placeholders: dict[str, str] = {} if user_input is not None: - result = await self._create_entry(user_input[CONF_HOST], user_input, errors) + result = await self._create_entry( + user_input[CONF_HOST], user_input, errors, placeholders + ) if result: return result @@ -83,8 +102,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): { vol.Required(CONF_HOST): str, vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_SSL, default=False): bool, + vol.Optional(CONF_VERIFY_SSL, default=False): bool, } ), + description_placeholders=placeholders, errors=errors, ) @@ -127,6 +149,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=vol.Schema( { vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_SSL, default=False): bool, + vol.Optional(CONF_VERIFY_SSL, default=False): bool, } ), description_placeholders=placeholders, diff --git a/homeassistant/components/fully_kiosk/coordinator.py b/homeassistant/components/fully_kiosk/coordinator.py index 0cfc15268b4..203251351ae 100644 --- a/homeassistant/components/fully_kiosk/coordinator.py +++ b/homeassistant/components/fully_kiosk/coordinator.py @@ -6,7 +6,7 @@ from fullykiosk import FullyKiosk from fullykiosk.exceptions import FullyKioskError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_SSL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -19,11 +19,14 @@ class FullyKioskDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize.""" + self.use_ssl = entry.data.get(CONF_SSL, False) self.fully = FullyKiosk( async_get_clientsession(hass), entry.data[CONF_HOST], DEFAULT_PORT, entry.data[CONF_PASSWORD], + use_ssl=self.use_ssl, + verify_ssl=entry.data.get(CONF_VERIFY_SSL, False), ) super().__init__( hass, diff --git a/homeassistant/components/fully_kiosk/entity.py b/homeassistant/components/fully_kiosk/entity.py index 2fe367643ee..5fd9f75a6a0 100644 --- a/homeassistant/components/fully_kiosk/entity.py +++ b/homeassistant/components/fully_kiosk/entity.py @@ -1,6 +1,13 @@ """Base entity for the Fully Kiosk Browser integration.""" from __future__ import annotations +import json + +from yarl import URL + +from homeassistant.components import mqtt +from homeassistant.const import ATTR_CONNECTIONS +from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -29,18 +36,50 @@ class FullyKioskEntity(CoordinatorEntity[FullyKioskDataUpdateCoordinator], Entit def __init__(self, coordinator: FullyKioskDataUpdateCoordinator) -> None: """Initialize the Fully Kiosk Browser entity.""" super().__init__(coordinator=coordinator) + + url = URL.build( + scheme="https" if coordinator.use_ssl else "http", + host=coordinator.data["ip4"], + port=2323, + ) + device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.data["deviceID"])}, name=coordinator.data["deviceName"], manufacturer=coordinator.data["deviceManufacturer"], model=coordinator.data["deviceModel"], sw_version=coordinator.data["appVersionName"], - configuration_url=f"http://{coordinator.data['ip4']}:2323", + configuration_url=str(url), ) if "Mac" in coordinator.data and valid_global_mac_address( coordinator.data["Mac"] ): - device_info["connections"] = { + device_info[ATTR_CONNECTIONS] = { (CONNECTION_NETWORK_MAC, coordinator.data["Mac"]) } self._attr_device_info = device_info + + async def mqtt_subscribe( + self, event: str | None, event_callback: CALLBACK_TYPE + ) -> CALLBACK_TYPE | None: + """Subscribe to MQTT for a given event.""" + data = self.coordinator.data + if ( + event is None + or not mqtt.mqtt_config_entry_enabled(self.hass) + or not data["settings"]["mqttEnabled"] + ): + return None + + @callback + def message_callback(message: mqtt.ReceiveMessage) -> None: + payload = json.loads(message.payload) + event_callback(**payload) + + topic_template = data["settings"]["mqttEventTopic"] + topic = ( + topic_template.replace("$appId", "fully") + .replace("$event", event) + .replace("$deviceId", data["deviceID"]) + ) + return await mqtt.async_subscribe(self.hass, topic, message_callback) diff --git a/homeassistant/components/fully_kiosk/manifest.json b/homeassistant/components/fully_kiosk/manifest.json index dcd36671fce..b5dadf14184 100644 --- a/homeassistant/components/fully_kiosk/manifest.json +++ b/homeassistant/components/fully_kiosk/manifest.json @@ -1,6 +1,7 @@ { "domain": "fully_kiosk", "name": "Fully Kiosk Browser", + "after_dependencies": ["mqtt"], "codeowners": ["@cgarwood"], "config_flow": true, "dhcp": [ diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index d61e8a7b7a8..c1a1ef1fcf0 100644 --- a/homeassistant/components/fully_kiosk/strings.json +++ b/homeassistant/components/fully_kiosk/strings.json @@ -10,13 +10,18 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of the device running your Fully Kiosk Browser application." } } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "cannot_connect": "Cannot connect. Details: {error_detail}", + "unknown": "Unknown. Details: {error_detail}" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" diff --git a/homeassistant/components/fully_kiosk/switch.py b/homeassistant/components/fully_kiosk/switch.py index 500e154abd8..c1d5d4e5c75 100644 --- a/homeassistant/components/fully_kiosk/switch.py +++ b/homeassistant/components/fully_kiosk/switch.py @@ -10,7 +10,7 @@ from fullykiosk import FullyKiosk 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.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -25,6 +25,8 @@ class FullySwitchEntityDescriptionMixin: on_action: Callable[[FullyKiosk], Any] off_action: Callable[[FullyKiosk], Any] is_on_fn: Callable[[dict[str, Any]], Any] + mqtt_on_event: str | None + mqtt_off_event: str | None @dataclass @@ -41,6 +43,8 @@ SWITCHES: tuple[FullySwitchEntityDescription, ...] = ( on_action=lambda fully: fully.startScreensaver(), off_action=lambda fully: fully.stopScreensaver(), is_on_fn=lambda data: data.get("isInScreensaver"), + mqtt_on_event="onScreensaverStart", + mqtt_off_event="onScreensaverStop", ), FullySwitchEntityDescription( key="maintenance", @@ -49,6 +53,8 @@ SWITCHES: tuple[FullySwitchEntityDescription, ...] = ( on_action=lambda fully: fully.enableLockedMode(), off_action=lambda fully: fully.disableLockedMode(), is_on_fn=lambda data: data.get("maintenanceMode"), + mqtt_on_event=None, + mqtt_off_event=None, ), FullySwitchEntityDescription( key="kiosk", @@ -57,6 +63,8 @@ SWITCHES: tuple[FullySwitchEntityDescription, ...] = ( on_action=lambda fully: fully.lockKiosk(), off_action=lambda fully: fully.unlockKiosk(), is_on_fn=lambda data: data.get("kioskLocked"), + mqtt_on_event=None, + mqtt_off_event=None, ), FullySwitchEntityDescription( key="motion-detection", @@ -65,6 +73,8 @@ SWITCHES: tuple[FullySwitchEntityDescription, ...] = ( on_action=lambda fully: fully.enableMotionDetection(), off_action=lambda fully: fully.disableMotionDetection(), is_on_fn=lambda data: data["settings"].get("motionDetection"), + mqtt_on_event=None, + mqtt_off_event=None, ), FullySwitchEntityDescription( key="screenOn", @@ -72,6 +82,8 @@ SWITCHES: tuple[FullySwitchEntityDescription, ...] = ( on_action=lambda fully: fully.screenOn(), off_action=lambda fully: fully.screenOff(), is_on_fn=lambda data: data.get("screenOn"), + mqtt_on_event="screenOn", + mqtt_off_event="screenOff", ), ) @@ -105,13 +117,27 @@ class FullySwitchEntity(FullyKioskEntity, SwitchEntity): super().__init__(coordinator) self.entity_description = description self._attr_unique_id = f"{coordinator.data['deviceID']}-{description.key}" + self._turned_on_subscription: CALLBACK_TYPE | None = None + self._turned_off_subscription: CALLBACK_TYPE | None = None - @property - def is_on(self) -> bool | None: - """Return true if the entity is on.""" - if (is_on := self.entity_description.is_on_fn(self.coordinator.data)) is None: - return None - return bool(is_on) + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + description = self.entity_description + self._turned_on_subscription = await self.mqtt_subscribe( + description.mqtt_off_event, self._turn_off + ) + self._turned_off_subscription = await self.mqtt_subscribe( + description.mqtt_on_event, self._turn_on + ) + + async def async_will_remove_from_hass(self) -> None: + """Close MQTT subscriptions when removed.""" + await super().async_will_remove_from_hass() + if self._turned_off_subscription is not None: + self._turned_off_subscription() + if self._turned_on_subscription is not None: + self._turned_on_subscription() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" @@ -122,3 +148,19 @@ class FullySwitchEntity(FullyKioskEntity, SwitchEntity): """Turn the entity off.""" await self.entity_description.off_action(self.coordinator.fully) await self.coordinator.async_refresh() + + def _turn_off(self, **kwargs: Any) -> None: + """Optimistically turn off.""" + self._attr_is_on = False + self.async_write_ha_state() + + def _turn_on(self, **kwargs: Any) -> None: + """Optimistically turn on.""" + self._attr_is_on = True + self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_is_on = bool(self.entity_description.is_on_fn(self.coordinator.data)) + self.async_write_ha_state() diff --git a/homeassistant/components/garages_amsterdam/manifest.json b/homeassistant/components/garages_amsterdam/manifest.json index 3f4ffc7fae1..3ce96152337 100644 --- a/homeassistant/components/garages_amsterdam/manifest.json +++ b/homeassistant/components/garages_amsterdam/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/garages_amsterdam", "iot_class": "cloud_polling", - "requirements": ["odp-amsterdam==5.3.1"] + "requirements": ["odp-amsterdam==6.0.0"] } diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 621566a70f5..9ffd873efd6 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -33,6 +33,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template as template_helper +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -172,6 +173,11 @@ class GenericCamera(Camera): self._last_url = None self._last_image = None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, identifier)}, + manufacturer="Generic", + ) + @property def use_stream_for_stills(self) -> bool: """Whether or not to use stream to generate stills.""" diff --git a/homeassistant/components/geniushub/switch.py b/homeassistant/components/geniushub/switch.py index 79ba418d509..7b9bf8f6112 100644 --- a/homeassistant/components/geniushub/switch.py +++ b/homeassistant/components/geniushub/switch.py @@ -68,9 +68,12 @@ class GeniusSwitch(GeniusZone, SwitchEntity): def is_on(self) -> bool: """Return the current state of the on/off zone. - The zone is considered 'on' if & only if it is override/on (e.g. timer/on is 'off'). + The zone is considered 'on' if the mode is either 'override' or 'timer'. """ - return self._zone.data["mode"] == "override" and self._zone.data["setpoint"] + return ( + self._zone.data["mode"] in ["override", "timer"] + and self._zone.data["setpoint"] + ) async def async_turn_off(self, **kwargs: Any) -> None: """Send the zone to Timer mode. diff --git a/homeassistant/components/geo_json_events/config_flow.py b/homeassistant/components/geo_json_events/config_flow.py index cf58e8b57ce..ffa1c2070e9 100644 --- a/homeassistant/components/geo_json_events/config_flow.py +++ b/homeassistant/components/geo_json_events/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Mapping -import logging from typing import Any import voluptuous as vol @@ -20,7 +19,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv, selector from homeassistant.util.unit_conversion import DistanceConverter -from .const import DEFAULT_RADIUS_IN_KM, DEFAULT_RADIUS_IN_M, DOMAIN +from .const import DEFAULT_RADIUS_IN_M, DOMAIN DATA_SCHEMA = vol.Schema( { @@ -31,34 +30,10 @@ DATA_SCHEMA = vol.Schema( } ) -_LOGGER = logging.getLogger(__name__) - class GeoJsonEventsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a GeoJSON events config flow.""" - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - url: str = import_config[CONF_URL] - latitude: float = import_config.get(CONF_LATITUDE, self.hass.config.latitude) - longitude: float = import_config.get(CONF_LONGITUDE, self.hass.config.longitude) - self._async_abort_entries_match( - { - CONF_URL: url, - CONF_LATITUDE: latitude, - CONF_LONGITUDE: longitude, - } - ) - return self.async_create_entry( - title=f"{url} ({latitude}, {longitude})", - data={ - CONF_URL: url, - CONF_LATITUDE: latitude, - CONF_LONGITUDE: longitude, - CONF_RADIUS: import_config.get(CONF_RADIUS, DEFAULT_RADIUS_IN_KM), - }, - ) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index c0192a0037d..8cb30535e66 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -6,28 +6,17 @@ import logging from typing import Any from aio_geojson_generic_client.feed_entry import GenericFeedEntry -import voluptuous as vol -from homeassistant.components.geo_location import PLATFORM_SCHEMA, GeolocationEvent -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_RADIUS, - CONF_URL, - UnitOfLength, -) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.components.geo_location import GeolocationEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfLength +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import GeoJsonFeedEntityManager from .const import ( ATTR_EXTERNAL_ID, - DEFAULT_RADIUS_IN_KM, DOMAIN, SIGNAL_DELETE_ENTITY, SIGNAL_UPDATE_ENTITY, @@ -36,16 +25,6 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -# Deprecated. -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_URL): cv.string, - vol.Optional(CONF_LATITUDE): cv.latitude, - vol.Optional(CONF_LONGITUDE): cv.longitude, - vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float), - } -) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -72,34 +51,6 @@ async def async_setup_entry( _LOGGER.debug("Geolocation setup done") -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the GeoJSON Events platform.""" - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2023.12.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "GeoJSON feed", - }, - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - - class GeoJsonLocationEvent(GeolocationEvent): """Represents an external event with GeoJSON data.""" diff --git a/homeassistant/components/geocaching/strings.json b/homeassistant/components/geocaching/strings.json index 6dc2fe8ec1c..9989af9a75c 100644 --- a/homeassistant/components/geocaching/strings.json +++ b/homeassistant/components/geocaching/strings.json @@ -16,7 +16,10 @@ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" @@ -24,21 +27,11 @@ }, "entity": { "sensor": { - "find_count": { - "name": "Total finds" - }, - "hide_count": { - "name": "Total hides" - }, - "favorite_points": { - "name": "Favorite points" - }, - "souvenir_count": { - "name": "Total souvenirs" - }, - "awarded_favorite_points": { - "name": "Awarded favorite points" - } + "find_count": { "name": "Total finds" }, + "hide_count": { "name": "Total hides" }, + "favorite_points": { "name": "Favorite points" }, + "souvenir_count": { "name": "Total souvenirs" }, + "awarded_favorite_points": { "name": "Awarded favorite points" } } } } diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index fece0b09f60..2e33bc6741e 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], "quality_scale": "platinum", - "requirements": ["gios==3.2.0"] + "requirements": ["gios==3.2.2"] } diff --git a/homeassistant/components/glances/strings.json b/homeassistant/components/glances/strings.json index fdd0c44b31b..1bab098d65f 100644 --- a/homeassistant/components/glances/strings.json +++ b/homeassistant/components/glances/strings.json @@ -10,6 +10,9 @@ "version": "Glances API Version (2 or 3)", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of the system running your Glances system monitor." } }, "reauth_confirm": { diff --git a/homeassistant/components/goalzero/strings.json b/homeassistant/components/goalzero/strings.json index d94f5219607..c6d85bd4c10 100644 --- a/homeassistant/components/goalzero/strings.json +++ b/homeassistant/components/goalzero/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "The hostname or IP address of your Goal Zero Yeti." } }, "confirm_discovery": { diff --git a/homeassistant/components/goodwe/manifest.json b/homeassistant/components/goodwe/manifest.json index f40d2253614..03575f9f4e2 100644 --- a/homeassistant/components/goodwe/manifest.json +++ b/homeassistant/components/goodwe/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/goodwe", "iot_class": "local_polling", "loggers": ["goodwe"], - "requirements": ["goodwe==0.2.31"] + "requirements": ["goodwe==0.2.32"] } diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py index 332280bac5a..0065d70dda9 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -79,12 +79,12 @@ _ICONS: dict[SensorKind, str] = { class GoodweSensorEntityDescription(SensorEntityDescription): """Class describing Goodwe sensor entities.""" - value: Callable[ - [GoodweUpdateCoordinator, str], Any - ] = lambda coordinator, sensor: coordinator.sensor_value(sensor) - available: Callable[ - [GoodweUpdateCoordinator], bool - ] = lambda coordinator: coordinator.last_update_success + value: Callable[[GoodweUpdateCoordinator, str], Any] = ( + lambda coordinator, sensor: coordinator.sensor_value(sensor) + ) + available: Callable[[GoodweUpdateCoordinator], bool] = ( + lambda coordinator: coordinator.last_update_success + ) _DESCRIPTIONS: dict[str, GoodweSensorEntityDescription] = { diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py index f37e120db68..8ed18cca41c 100644 --- a/homeassistant/components/google/api.py +++ b/homeassistant/components/google/api.py @@ -45,11 +45,18 @@ class OAuthError(Exception): """OAuth related error.""" -class DeviceAuth(AuthImplementation): - """OAuth implementation for Device Auth.""" +class InvalidCredential(OAuthError): + """Error with an invalid credential that does not support device auth.""" + + +class GoogleHybridAuth(AuthImplementation): + """OAuth implementation that supports both Web Auth (base class) and Device Auth.""" async def async_resolve_external_data(self, external_data: Any) -> dict: """Resolve a Google API Credentials object to Home Assistant token.""" + if DEVICE_AUTH_CREDS not in external_data: + # Assume the Web Auth flow was used, so use the default behavior + return await super().async_resolve_external_data(external_data) creds: Credentials = external_data[DEVICE_AUTH_CREDS] delta = creds.token_expiry.replace(tzinfo=datetime.UTC) - dt_util.utcnow() _LOGGER.debug( @@ -192,6 +199,10 @@ async def async_create_device_flow( oauth_flow.step1_get_device_and_user_codes ) except OAuth2DeviceCodeError as err: + _LOGGER.debug("OAuth2DeviceCodeError error: %s", err) + # Web auth credentials reply with invalid_client when hitting this endpoint + if "Error: invalid_client" in str(err): + raise InvalidCredential(str(err)) from err raise OAuthError(str(err)) from err return DeviceFlow(hass, oauth_flow, device_flow_info) diff --git a/homeassistant/components/google/application_credentials.py b/homeassistant/components/google/application_credentials.py index 60ad9b3275e..bb1ddfef5d7 100644 --- a/homeassistant/components/google/application_credentials.py +++ b/homeassistant/components/google/application_credentials.py @@ -9,7 +9,7 @@ from homeassistant.components.application_credentials import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow -from .api import DeviceAuth +from .api import GoogleHybridAuth AUTHORIZATION_SERVER = AuthorizationServer( oauth2client.GOOGLE_AUTH_URI, oauth2client.GOOGLE_TOKEN_URI @@ -20,7 +20,7 @@ async def async_get_auth_implementation( hass: HomeAssistant, auth_domain: str, credential: ClientCredential ) -> config_entry_oauth2_flow.AbstractOAuth2Implementation: """Return auth implementation.""" - return DeviceAuth(hass, auth_domain, credential, AUTHORIZATION_SERVER) + return GoogleHybridAuth(hass, auth_domain, credential, AUTHORIZATION_SERVER) async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py index 1945afe15e9..33d913fe8f1 100644 --- a/homeassistant/components/google/config_flow.py +++ b/homeassistant/components/google/config_flow.py @@ -18,13 +18,21 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .api import ( DEVICE_AUTH_CREDS, AccessTokenAuthImpl, - DeviceAuth, DeviceFlow, + GoogleHybridAuth, + InvalidCredential, OAuthError, async_create_device_flow, get_feature_access, ) -from .const import CONF_CALENDAR_ACCESS, DOMAIN, FeatureAccess +from .const import ( + CONF_CALENDAR_ACCESS, + CONF_CREDENTIAL_TYPE, + DEFAULT_FEATURE_ACCESS, + DOMAIN, + CredentialType, + FeatureAccess, +) _LOGGER = logging.getLogger(__name__) @@ -32,7 +40,31 @@ _LOGGER = logging.getLogger(__name__) class OAuth2FlowHandler( config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN ): - """Config flow to handle Google Calendars OAuth2 authentication.""" + """Config flow to handle Google Calendars OAuth2 authentication. + + Historically, the Google Calendar integration instructed users to use + Device Auth. Device Auth was considered easier to use since it did not + require users to configure a redirect URL. Device Auth is meant for + devices with limited input, such as a television. + https://developers.google.com/identity/protocols/oauth2/limited-input-device + + Device Auth is limited to a small set of Google APIs (calendar is allowed) + and is considered less secure than Web Auth. It is not generally preferred + and may be limited/deprecated in the future similar to App/OOB Auth + https://developers.googleblog.com/2022/02/making-oauth-flows-safer.html + + Web Auth is the preferred method by Home Assistant and Google, and a benefit + is that the same credentials may be used across many Google integrations in + Home Assistant. Web Auth is now easier for user to setup using my.home-assistant.io + redirect urls. + + The Application Credentials integration does not currently record which type + of credential the user entered (and if we ask the user, they may not know or may + make a mistake) so we try to determine the credential type automatically. This + implementation first attempts Device Auth by talking to the token API in the first + step of the device flow, then if that fails it will redirect using Web Auth. + There is not another explicit known way to check. + """ DOMAIN = DOMAIN @@ -41,12 +73,24 @@ class OAuth2FlowHandler( super().__init__() self._reauth_config_entry: config_entries.ConfigEntry | None = None self._device_flow: DeviceFlow | None = None + # First attempt is device auth, then fallback to web auth + self._web_auth = False @property def logger(self) -> logging.Logger: """Return logger.""" return logging.getLogger(__name__) + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return { + "scope": DEFAULT_FEATURE_ACCESS.scope, + # Add params to ensure we get back a refresh token + "access_type": "offline", + "prompt": "consent", + } + async def async_step_import(self, info: dict[str, Any]) -> FlowResult: """Import existing auth into a new config entry.""" if self._async_current_entries(): @@ -68,12 +112,15 @@ class OAuth2FlowHandler( # prompt the user to visit a URL and enter a code. The device flow # background task will poll the exchange endpoint to get valid # creds or until a timeout is complete. + if self._web_auth: + return await super().async_step_auth(user_input) + if user_input is not None: return self.async_show_progress_done(next_step_id="creation") if not self._device_flow: - _LOGGER.debug("Creating DeviceAuth flow") - if not isinstance(self.flow_impl, DeviceAuth): + _LOGGER.debug("Creating GoogleHybridAuth flow") + if not isinstance(self.flow_impl, GoogleHybridAuth): _LOGGER.error( "Unexpected OAuth implementation does not support device auth: %s", self.flow_impl, @@ -94,6 +141,10 @@ class OAuth2FlowHandler( except TimeoutError as err: _LOGGER.error("Timeout initializing device flow: %s", str(err)) return self.async_abort(reason="timeout_connect") + except InvalidCredential: + _LOGGER.debug("Falling back to Web Auth and restarting flow") + self._web_auth = True + return await super().async_step_auth() except OAuthError as err: _LOGGER.error("Error initializing device flow: %s", str(err)) return self.async_abort(reason="oauth_error") @@ -125,12 +176,15 @@ class OAuth2FlowHandler( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle external yaml configuration.""" - if self.external_data.get(DEVICE_AUTH_CREDS) is None: + if not self._web_auth and self.external_data.get(DEVICE_AUTH_CREDS) is None: return self.async_abort(reason="code_expired") return await super().async_step_creation(user_input) async def async_oauth_create_entry(self, data: dict) -> FlowResult: """Create an entry for the flow, or update existing entry.""" + data[CONF_CREDENTIAL_TYPE] = ( + CredentialType.WEB_AUTH if self._web_auth else CredentialType.DEVICE_AUTH + ) if self._reauth_config_entry: self.hass.config_entries.async_update_entry( self._reauth_config_entry, data=data @@ -170,6 +224,7 @@ class OAuth2FlowHandler( self._reauth_config_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) + self._web_auth = entry_data.get(CONF_CREDENTIAL_TYPE) == CredentialType.WEB_AUTH return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/google/const.py b/homeassistant/components/google/const.py index add98441e39..6f497543b2d 100644 --- a/homeassistant/components/google/const.py +++ b/homeassistant/components/google/const.py @@ -1,12 +1,12 @@ """Constants for google integration.""" from __future__ import annotations -from enum import Enum +from enum import Enum, StrEnum DOMAIN = "google" -DEVICE_AUTH_IMPL = "device_auth" CONF_CALENDAR_ACCESS = "calendar_access" +CONF_CREDENTIAL_TYPE = "credential_type" DATA_CALENDARS = "calendars" DATA_SERVICE = "service" DATA_CONFIG = "config" @@ -32,6 +32,13 @@ class FeatureAccess(Enum): DEFAULT_FEATURE_ACCESS = FeatureAccess.read_write +class CredentialType(StrEnum): + """Type of application credentials used.""" + + DEVICE_AUTH = "device_auth" + WEB_AUTH = "web_auth" + + EVENT_DESCRIPTION = "description" EVENT_END_DATE = "end_date" EVENT_END_DATETIME = "end_date_time" diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index fc9107bb8d2..27e462a380e 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/calendar.google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==6.0.1", "oauth2client==4.1.3"] + "requirements": ["gcal-sync==6.0.3", "oauth2client==4.1.3"] } diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index b3594f31510..4e62b134b0e 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -22,7 +22,10 @@ "code_expired": "Authentication code expired or credential setup is invalid, please try again.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", - "api_disabled": "You must enable the Google Calendar API in the Google Cloud Console" + "api_disabled": "You must enable the Google Calendar API in the Google Cloud Console", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index b2cda5522ee..c89925664e0 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -15,7 +15,7 @@ from aiohttp.web import json_response from awesomeversion import AwesomeVersion from yarl import URL -from homeassistant.components import webhook +from homeassistant.components import matter, webhook from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_SUPPORTED_FEATURES, @@ -59,7 +59,11 @@ LOCAL_SDK_MIN_VERSION = AwesomeVersion("2.1.5") @callback def _get_registry_entries( hass: HomeAssistant, entity_id: str -) -> tuple[er.RegistryEntry | None, dr.DeviceEntry | None, ar.AreaEntry | None,]: +) -> tuple[ + er.RegistryEntry | None, + dr.DeviceEntry | None, + ar.AreaEntry | None, +]: """Get registry entries.""" ent_reg = er.async_get(hass) dev_reg = dr.async_get(hass) @@ -678,10 +682,22 @@ class GoogleEntity: elif area_entry and area_entry.name: device["roomHint"] = area_entry.name - # Add deviceInfo if not device_entry: return device + # Add Matter info + if ( + "matter" in self.hass.config.components + and any(x for x in device_entry.identifiers if x[0] == "matter") + and ( + matter_info := matter.get_matter_device_info(self.hass, device_entry.id) + ) + ): + device["matterUniqueId"] = matter_info["unique_id"] + device["matterOriginalVendorId"] = matter_info["vendor_id"] + device["matterOriginalProductId"] = matter_info["product_id"] + + # Add deviceInfo device_info = {} if device_entry.manufacturer: diff --git a/homeassistant/components/google_assistant/manifest.json b/homeassistant/components/google_assistant/manifest.json index 3c7ac043441..e36f6a1ca87 100644 --- a/homeassistant/components/google_assistant/manifest.json +++ b/homeassistant/components/google_assistant/manifest.json @@ -1,7 +1,7 @@ { "domain": "google_assistant", "name": "Google Assistant", - "after_dependencies": ["camera"], + "after_dependencies": ["camera", "matter"], "codeowners": ["@home-assistant/cloud"], "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/google_assistant", diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index e9e2b7d4c09..d5d1d885427 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -21,7 +21,10 @@ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 1154c7132d2..c507e0c046d 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -88,7 +88,7 @@ class GoogleGenerativeAIAgent(conversation.AbstractConversationAgent): conversation_id = user_input.conversation_id messages = self.history[conversation_id] else: - conversation_id = ulid.ulid() + conversation_id = ulid.ulid_now() messages = [] try: diff --git a/homeassistant/components/google_mail/strings.json b/homeassistant/components/google_mail/strings.json index 2bd70750ff9..142e8f039d2 100644 --- a/homeassistant/components/google_mail/strings.json +++ b/homeassistant/components/google_mail/strings.json @@ -22,7 +22,10 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "wrong_account": "Wrong account: Please authenticate with {email}." + "wrong_account": "Wrong account: Please authenticate with {email}.", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/google_sheets/strings.json b/homeassistant/components/google_sheets/strings.json index b2cba19031e..e498e36723e 100644 --- a/homeassistant/components/google_sheets/strings.json +++ b/homeassistant/components/google_sheets/strings.json @@ -4,9 +4,7 @@ "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, - "auth": { - "title": "Link Google Account" - }, + "auth": { "title": "Link Google Account" }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The Google Sheets integration needs to re-authenticate your account" @@ -23,7 +21,10 @@ "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", "unknown": "[%key:common::config_flow::error::unknown%]", "create_spreadsheet_failure": "Error while creating spreadsheet, see error log for details", - "open_spreadsheet_failure": "Error while opening spreadsheet, see error log for details" + "open_spreadsheet_failure": "Error while opening spreadsheet, see error log for details", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "Successfully authenticated and spreadsheet created at: {url}" diff --git a/homeassistant/components/google_tasks/api.py b/homeassistant/components/google_tasks/api.py index d42926c3bf6..5dd7156702f 100644 --- a/homeassistant/components/google_tasks/api.py +++ b/homeassistant/components/google_tasks/api.py @@ -1,18 +1,36 @@ """API for Google Tasks bound to Home Assistant OAuth.""" +import json +import logging from typing import Any from google.oauth2.credentials import Credentials from googleapiclient.discovery import Resource, build -from googleapiclient.http import HttpRequest +from googleapiclient.errors import HttpError +from googleapiclient.http import BatchHttpRequest, HttpRequest from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow +from .exceptions import GoogleTasksApiError + +_LOGGER = logging.getLogger(__name__) + MAX_TASK_RESULTS = 100 +def _raise_if_error(result: Any | dict[str, Any]) -> None: + """Raise a GoogleTasksApiError if the response contains an error.""" + if not isinstance(result, dict): + raise GoogleTasksApiError( + f"Google Tasks API replied with unexpected response: {result}" + ) + if error := result.get("error"): + message = error.get("message", "Unknown Error") + raise GoogleTasksApiError(f"Google Tasks API response: {message}") + + class AsyncConfigEntryAuth: """Provide Google Tasks authentication tied to an OAuth2 based config entry.""" @@ -40,7 +58,7 @@ class AsyncConfigEntryAuth: """Get all TaskList resources.""" service = await self._get_service() cmd: HttpRequest = service.tasklists().list() - result = await self._hass.async_add_executor_job(cmd.execute) + result = await self._execute(cmd) return result["items"] async def list_tasks(self, task_list_id: str) -> list[dict[str, Any]]: @@ -49,7 +67,7 @@ class AsyncConfigEntryAuth: cmd: HttpRequest = service.tasks().list( tasklist=task_list_id, maxResults=MAX_TASK_RESULTS ) - result = await self._hass.async_add_executor_job(cmd.execute) + result = await self._execute(cmd) return result["items"] async def insert( @@ -63,7 +81,7 @@ class AsyncConfigEntryAuth: tasklist=task_list_id, body=task, ) - await self._hass.async_add_executor_job(cmd.execute) + await self._execute(cmd) async def patch( self, @@ -78,4 +96,43 @@ class AsyncConfigEntryAuth: task=task_id, body=task, ) - await self._hass.async_add_executor_job(cmd.execute) + await self._execute(cmd) + + async def delete( + self, + task_list_id: str, + task_ids: list[str], + ) -> None: + """Delete a task resources.""" + service = await self._get_service() + batch: BatchHttpRequest = service.new_batch_http_request() + + def response_handler(_, response, exception: HttpError) -> None: + if exception is not None: + raise GoogleTasksApiError( + f"Google Tasks API responded with error ({exception.status_code})" + ) from exception + data = json.loads(response) + _raise_if_error(data) + + for task_id in task_ids: + batch.add( + service.tasks().delete( + tasklist=task_list_id, + task=task_id, + ), + request_id=task_id, + callback=response_handler, + ) + await self._execute(batch) + + async def _execute(self, request: HttpRequest | BatchHttpRequest) -> Any: + try: + result = await self._hass.async_add_executor_job(request.execute) + except HttpError as err: + raise GoogleTasksApiError( + f"Google Tasks API responded with error ({err.status_code})" + ) from err + if result: + _raise_if_error(result) + return result diff --git a/homeassistant/components/google_tasks/exceptions.py b/homeassistant/components/google_tasks/exceptions.py new file mode 100644 index 00000000000..406a3a69d51 --- /dev/null +++ b/homeassistant/components/google_tasks/exceptions.py @@ -0,0 +1,7 @@ +"""Exceptions for Google Tasks api calls.""" + +from homeassistant.exceptions import HomeAssistantError + + +class GoogleTasksApiError(HomeAssistantError): + """Error talking to the Google Tasks API.""" diff --git a/homeassistant/components/google_tasks/strings.json b/homeassistant/components/google_tasks/strings.json index f15c31f42d4..2cf15f0d93d 100644 --- a/homeassistant/components/google_tasks/strings.json +++ b/homeassistant/components/google_tasks/strings.json @@ -17,7 +17,10 @@ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", "access_not_configured": "Unable to access the Google API:\n\n{message}", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py index 5d2da33da71..130c0d2cc01 100644 --- a/homeassistant/components/google_tasks/todo.py +++ b/homeassistant/components/google_tasks/todo.py @@ -1,8 +1,8 @@ """Google Tasks todo platform.""" from __future__ import annotations -from datetime import timedelta -from typing import cast +from datetime import date, datetime, timedelta +from typing import Any, cast from homeassistant.components.todo import ( TodoItem, @@ -14,6 +14,7 @@ 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 @@ -35,9 +36,31 @@ def _convert_todo_item(item: TodoItem) -> dict[str, str]: result["title"] = item.summary if item.status is not None: result["status"] = TODO_STATUS_MAP_INV[item.status] + 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() + if (description := item.description) is not None: + result["notes"] = description return result +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 = datetime.fromisoformat(due_str).date() + return TodoItem( + summary=item["title"], + uid=item["id"], + status=TODO_STATUS_MAP.get( + item.get("status", ""), + TodoItemStatus.NEEDS_ACTION, + ), + due=due, + description=item.get("notes"), + ) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -65,7 +88,11 @@ class GoogleTaskTodoListEntity( _attr_has_entity_name = True _attr_supported_features = ( - TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) def __init__( @@ -86,16 +113,7 @@ class GoogleTaskTodoListEntity( """Get the current set of To-do items.""" if self.coordinator.data is None: return None - return [ - TodoItem( - summary=item["title"], - uid=item["id"], - status=TODO_STATUS_MAP.get( - item.get("status"), TodoItemStatus.NEEDS_ACTION # type: ignore[arg-type] - ), - ) - for item in self.coordinator.data - ] + return [_convert_api_item(item) for item in _order_tasks(self.coordinator.data)] async def async_create_todo_item(self, item: TodoItem) -> None: """Add an item to the To-do list.""" @@ -114,3 +132,21 @@ class GoogleTaskTodoListEntity( task=_convert_todo_item(item), ) await self.coordinator.async_refresh() + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Delete To-do items.""" + await self.coordinator.api.delete(self._task_list_id, uids) + await self.coordinator.async_refresh() + + +def _order_tasks(tasks: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Order the task items response. + + All tasks have an order amongst their sibblings based on position. + + Home Assistant To-do items do not support the Google Task parent/sibbling + relationships and the desired behavior is for them to be filtered. + """ + parents = [task for task in tasks if task.get("parent") is None] + parents.sort(key=lambda task: task["position"]) + return parents diff --git a/homeassistant/components/gree/__init__.py b/homeassistant/components/gree/__init__.py index ff3438ed53f..13e93d780b2 100644 --- a/homeassistant/components/gree/__init__.py +++ b/homeassistant/components/gree/__init__.py @@ -11,7 +11,6 @@ from homeassistant.helpers.event import async_track_time_interval from .bridge import DiscoveryService from .const import ( COORDINATORS, - DATA_DISCOVERY_INTERVAL, DATA_DISCOVERY_SERVICE, DISCOVERY_SCAN_INTERVAL, DISPATCHERS, @@ -29,7 +28,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: gree_discovery = DiscoveryService(hass) hass.data[DATA_DISCOVERY_SERVICE] = gree_discovery - hass.data[DOMAIN].setdefault(DISPATCHERS, []) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def _async_scan_update(_=None): @@ -39,8 +37,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Scanning network for Gree devices") await _async_scan_update() - hass.data[DOMAIN][DATA_DISCOVERY_INTERVAL] = async_track_time_interval( - hass, _async_scan_update, timedelta(seconds=DISCOVERY_SCAN_INTERVAL) + entry.async_on_unload( + async_track_time_interval( + hass, _async_scan_update, timedelta(seconds=DISCOVERY_SCAN_INTERVAL) + ) ) return True @@ -48,13 +48,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if hass.data[DOMAIN].get(DISPATCHERS) is not None: - for cleanup in hass.data[DOMAIN][DISPATCHERS]: - cleanup() - - if hass.data[DOMAIN].get(DATA_DISCOVERY_INTERVAL) is not None: - hass.data[DOMAIN].pop(DATA_DISCOVERY_INTERVAL)() - if hass.data.get(DATA_DISCOVERY_SERVICE) is not None: hass.data.pop(DATA_DISCOVERY_SERVICE) diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index 17d915feadb..ba162173724 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -17,6 +17,7 @@ from greeclimate.device import ( ) from homeassistant.components.climate import ( + ATTR_HVAC_MODE, FAN_AUTO, FAN_HIGH, FAN_LOW, @@ -46,7 +47,6 @@ from .bridge import DeviceDataUpdateCoordinator from .const import ( COORDINATORS, DISPATCH_DEVICE_DISCOVERED, - DISPATCHERS, DOMAIN, FAN_MEDIUM_HIGH, FAN_MEDIUM_LOW, @@ -87,7 +87,7 @@ SWING_MODES = [SWING_OFF, SWING_VERTICAL, SWING_HORIZONTAL, SWING_BOTH] async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Gree HVAC device from a config entry.""" @@ -100,7 +100,7 @@ async def async_setup_entry( for coordinator in hass.data[DOMAIN][COORDINATORS]: init_device(coordinator) - hass.data[DOMAIN][DISPATCHERS].append( + entry.async_on_unload( async_dispatcher_connect(hass, DISPATCH_DEVICE_DISCOVERED, init_device) ) @@ -158,6 +158,9 @@ class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateE if ATTR_TEMPERATURE not in kwargs: raise ValueError(f"Missing parameter {ATTR_TEMPERATURE}") + if hvac_mode := kwargs.get(ATTR_HVAC_MODE): + await self.async_set_hvac_mode(hvac_mode) + temperature = kwargs[ATTR_TEMPERATURE] _LOGGER.debug( "Setting temperature to %d for %s", diff --git a/homeassistant/components/gree/const.py b/homeassistant/components/gree/const.py index b4df7a1acde..46479210921 100644 --- a/homeassistant/components/gree/const.py +++ b/homeassistant/components/gree/const.py @@ -3,7 +3,6 @@ COORDINATORS = "coordinators" DATA_DISCOVERY_SERVICE = "gree_discovery" -DATA_DISCOVERY_INTERVAL = "gree_discovery_interval" DISCOVERY_SCAN_INTERVAL = 300 DISCOVERY_TIMEOUT = 8 diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index 68c11ad6e1f..7916df18abc 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DISPATCHERS, DOMAIN +from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN from .entity import GreeEntity @@ -102,7 +102,7 @@ GREE_SWITCHES: tuple[GreeSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Gree HVAC device from a config entry.""" @@ -119,7 +119,7 @@ async def async_setup_entry( for coordinator in hass.data[DOMAIN][COORDINATORS]: init_device(coordinator) - hass.data[DOMAIN][DISPATCHERS].append( + entry.async_on_unload( async_dispatcher_connect(hass, DISPATCH_DEVICE_DISCOVERED, init_device) ) diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 82c2651e764..ae246041db9 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -293,14 +293,31 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await _async_process_config(hass, config) async def reload_service_handler(service: ServiceCall) -> None: - """Remove all user-defined groups and load new ones from config.""" - auto = [e for e in component.entities if not e.user_defined] + """Group reload handler. - if (conf := await component.async_prepare_reload()) is None: + - Remove group.group entities not created by service calls and set them up again + - Reload xxx.group platforms + """ + if (conf := await component.async_prepare_reload(skip_reset=True)) is None: return - await _async_process_config(hass, conf) - await component.async_add_entities(auto) + # Simplified + modified version of EntityPlatform.async_reset: + # - group.group never retries setup + # - group.group never polls + # - We don't need to reset EntityPlatform._setup_complete + # - Only remove entities which were not created by service calls + tasks = [ + entity.async_remove() + for entity in component.entities + if entity.entity_id.startswith("group.") and not entity.created_by_service + ] + + if tasks: + await asyncio.gather(*tasks) + + component.config = None + + await _async_process_config(hass, conf) await async_reload_integration_platforms(hass, DOMAIN, PLATFORMS) @@ -329,20 +346,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: or None ) - extra_arg = { - attr: service.data[attr] - for attr in (ATTR_ICON,) - if service.data.get(attr) is not None - } - await Group.async_create_group( hass, service.data.get(ATTR_NAME, object_id), - object_id=object_id, + created_by_service=True, entity_ids=entity_ids, - user_defined=False, + icon=service.data.get(ATTR_ICON), mode=service.data.get(ATTR_ALL), - **extra_arg, + object_id=object_id, + order=None, ) return @@ -449,7 +461,8 @@ async def _async_process_config(hass: HomeAssistant, config: ConfigType) -> None Group.async_create_group_entity( hass, name, - entity_ids, + created_by_service=False, + entity_ids=entity_ids, icon=icon, object_id=object_id, mode=mode, @@ -570,11 +583,12 @@ class Group(Entity): self, hass: HomeAssistant, name: str, - order: int | None = None, - icon: str | None = None, - user_defined: bool = True, - entity_ids: Collection[str] | None = None, - mode: bool | None = None, + *, + created_by_service: bool, + entity_ids: Collection[str] | None, + icon: str | None, + mode: bool | None, + order: int | None, ) -> None: """Initialize a group. @@ -588,7 +602,7 @@ class Group(Entity): self._on_off: dict[str, bool] = {} self._assumed: dict[str, bool] = {} self._on_states: set[str] = set() - self.user_defined = user_defined + self.created_by_service = created_by_service self.mode = any if mode: self.mode = all @@ -596,36 +610,18 @@ class Group(Entity): self._assumed_state = False self._async_unsub_state_changed: CALLBACK_TYPE | None = None - @staticmethod - def create_group( - hass: HomeAssistant, - name: str, - entity_ids: Collection[str] | None = None, - user_defined: bool = True, - icon: str | None = None, - object_id: str | None = None, - mode: bool | None = None, - order: int | None = None, - ) -> Group: - """Initialize a group.""" - return asyncio.run_coroutine_threadsafe( - Group.async_create_group( - hass, name, entity_ids, user_defined, icon, object_id, mode, order - ), - hass.loop, - ).result() - @staticmethod @callback def async_create_group_entity( hass: HomeAssistant, name: str, - entity_ids: Collection[str] | None = None, - user_defined: bool = True, - icon: str | None = None, - object_id: str | None = None, - mode: bool | None = None, - order: int | None = None, + *, + created_by_service: bool, + entity_ids: Collection[str] | None, + icon: str | None, + mode: bool | None, + object_id: str | None, + order: int | None, ) -> Group: """Create a group entity.""" if order is None: @@ -639,11 +635,11 @@ class Group(Entity): group = Group( hass, name, - order=order, - icon=icon, - user_defined=user_defined, + created_by_service=created_by_service, entity_ids=entity_ids, + icon=icon, mode=mode, + order=order, ) group.entity_id = async_generate_entity_id( @@ -656,19 +652,27 @@ class Group(Entity): async def async_create_group( hass: HomeAssistant, name: str, - entity_ids: Collection[str] | None = None, - user_defined: bool = True, - icon: str | None = None, - object_id: str | None = None, - mode: bool | None = None, - order: int | None = None, + *, + created_by_service: bool, + entity_ids: Collection[str] | None, + icon: str | None, + mode: bool | None, + object_id: str | None, + order: int | None, ) -> Group: """Initialize a group. This method must be run in the event loop. """ group = Group.async_create_group_entity( - hass, name, entity_ids, user_defined, icon, object_id, mode, order + hass, + name, + created_by_service=created_by_service, + entity_ids=entity_ids, + icon=icon, + mode=mode, + object_id=object_id, + order=order, ) # If called before the platform async_setup is called (test cases) @@ -704,7 +708,7 @@ class Group(Entity): def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes for the group.""" data = {ATTR_ENTITY_ID: self.tracking, ATTR_ORDER: self._order} - if not self.user_defined: + if self.created_by_service: data[ATTR_AUTO] = True return data diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json index a21c811af47..d872474f1da 100644 --- a/homeassistant/components/growatt_server/manifest.json +++ b/homeassistant/components/growatt_server/manifest.json @@ -1,7 +1,7 @@ { "domain": "growatt_server", "name": "Growatt", - "codeowners": ["@muppet3000"], + "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/growatt_server", "iot_class": "cloud_polling", diff --git a/homeassistant/components/harmony/strings.json b/homeassistant/components/harmony/strings.json index 9ae22090d7f..f6862ca3c83 100644 --- a/homeassistant/components/harmony/strings.json +++ b/homeassistant/components/harmony/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "Hub Name" + }, + "data_description": { + "host": "The hostname or IP address of your Logitech Harmony Hub." } }, "link": { @@ -42,6 +45,16 @@ } } }, + "issues": { + "deprecated_switches": { + "title": "The Logitech Harmony switch platform is being removed", + "description": "Using the switch platform to change the current activity is now deprecated and will be removed in a future version of Home Assistant.\n\nPlease adjust any automations or scripts that use switch entities to instead use the select entity." + }, + "deprecated_switches_entity": { + "title": "Deprecated Harmony entity detected in {info}", + "description": "Your Harmony entity `{entity}` is being used in `{info}`. A select entity is available and should be used going forward.\n\nPlease adjust `{info}` to fix this issue." + } + }, "services": { "sync": { "name": "Sync", diff --git a/homeassistant/components/harmony/switch.py b/homeassistant/components/harmony/switch.py index acd04596bd5..2d072f11f2c 100644 --- a/homeassistant/components/harmony/switch.py +++ b/homeassistant/components/harmony/switch.py @@ -1,12 +1,15 @@ """Support for Harmony Hub activities.""" import logging -from typing import Any +from typing import Any, cast -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import DOMAIN, HARMONY_DATA from .data import HarmonyData @@ -53,10 +56,28 @@ class HarmonyActivitySwitch(HarmonyEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Start this activity.""" + async_create_issue( + self.hass, + DOMAIN, + "deprecated_switches", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_switches", + ) await self._data.async_start_activity(self._activity_name) async def async_turn_off(self, **kwargs: Any) -> None: """Stop this activity.""" + async_create_issue( + self.hass, + DOMAIN, + "deprecated_switches", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_switches", + ) await self._data.async_power_off() async def async_added_to_hass(self) -> None: @@ -72,6 +93,22 @@ class HarmonyActivitySwitch(HarmonyEntity, SwitchEntity): ) ) ) + entity_automations = automations_with_entity(self.hass, self.entity_id) + entity_scripts = scripts_with_entity(self.hass, self.entity_id) + for item in entity_automations + entity_scripts: + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_switches_{self.entity_id}_{item}", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_switches_entity", + translation_placeholders={ + "entity": f"{SWITCH_DOMAIN}.{cast(str, self.name).lower().replace(' ', '_')}", + "info": item, + }, + ) @callback def _async_activity_update(self, activity_info: tuple): diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 419d80484cf..9d72d5842fd 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -6,6 +6,7 @@ from http import HTTPStatus import logging import os import re +from typing import TYPE_CHECKING from urllib.parse import quote, unquote import aiohttp @@ -156,6 +157,9 @@ class HassIOView(HomeAssistantView): # _stored_content_type is only computed once `content_type` is accessed if path == "backups/new/upload": # We need to reuse the full content type that includes the boundary + if TYPE_CHECKING: + # pylint: disable-next=protected-access + assert isinstance(request._stored_content_type, str) # pylint: disable-next=protected-access headers[CONTENT_TYPE] = request._stored_content_type diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index b8c5873b967..751e9005809 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -169,6 +169,11 @@ class HassIOIngress(HomeAssistantView): headers = _response_header(result) content_length_int = 0 content_length = result.headers.get(hdrs.CONTENT_LENGTH, UNDEFINED) + # Avoid parsing content_type in simple cases for better performance + if maybe_content_type := result.headers.get(hdrs.CONTENT_TYPE): + content_type: str = (maybe_content_type.partition(";"))[0].strip() + else: + content_type = result.content_type # Simple request if result.status in (204, 304) or ( content_length is not UNDEFINED @@ -180,11 +185,12 @@ class HassIOIngress(HomeAssistantView): simple_response = web.Response( headers=headers, status=result.status, - content_type=result.content_type, + content_type=content_type, body=body, + zlib_executor_size=32768, ) if content_length_int > MIN_COMPRESSED_SIZE and should_compress( - simple_response.content_type + content_type or simple_response.content_type ): simple_response.enable_compression() await simple_response.prepare(request) diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py index 19621e28d03..54ea2f3e5bd 100644 --- a/homeassistant/components/hdmi_cec/__init__.py +++ b/homeassistant/components/hdmi_cec/__init__.py @@ -195,9 +195,7 @@ def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: # noqa: C901 loop = ( # Create own thread if more than 1 CPU - hass.loop - if multiprocessing.cpu_count() < 2 - else None + hass.loop if multiprocessing.cpu_count() < 2 else None ) host = base_config[DOMAIN].get(CONF_HOST) display_name = base_config[DOMAIN].get(CONF_DISPLAY_NAME, DEFAULT_DISPLAY_NAME) diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index 7bd362cf3d7..df18fc7834a 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -6,6 +6,9 @@ "description": "Please enter the host name or IP address of a Heos device (preferably one connected via wire to the network).", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your HEOS device." } } }, diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index f5b97a7fb13..9eab92dce5c 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -21,7 +21,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 +from .helpers import entities_may_have_state_changes_after, has_recorder_run_after CONF_ORDER = "use_include_order" @@ -106,7 +106,8 @@ class HistoryPeriodView(HomeAssistantView): no_attributes = "no_attributes" in request.query if ( - not include_start_time_state + (end_time and not has_recorder_run_after(hass, end_time)) + or not include_start_time_state and entity_ids and not entities_may_have_state_changes_after( hass, entity_ids, start_time, no_attributes diff --git a/homeassistant/components/history/helpers.py b/homeassistant/components/history/helpers.py index 523b1fafb7f..7e28e69e5f9 100644 --- a/homeassistant/components/history/helpers.py +++ b/homeassistant/components/history/helpers.py @@ -4,6 +4,8 @@ from __future__ import annotations 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 @@ -21,3 +23,10 @@ def entities_may_have_state_changes_after( return True return False + + +def has_recorder_run_after(hass: HomeAssistant, run_time: dt) -> bool: + """Check if the recorder has any runs after a specific time.""" + return run_time >= process_timestamp( + get_instance(hass).recorder_runs_manager.first.start + ) diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index 24ec07b6a87..4be63f29c02 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -39,7 +39,7 @@ from homeassistant.helpers.typing import EventType 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 +from .helpers import entities_may_have_state_changes_after, has_recorder_run_after _LOGGER = logging.getLogger(__name__) @@ -142,7 +142,8 @@ async def ws_get_history_during_period( no_attributes = msg["no_attributes"] if ( - not include_start_time_state + (end_time and not has_recorder_run_after(hass, end_time)) + or not include_start_time_state and entity_ids and not entities_may_have_state_changes_after( hass, entity_ids, start_time, no_attributes diff --git a/homeassistant/components/hlk_sw16/strings.json b/homeassistant/components/hlk_sw16/strings.json index d6e3212b4ea..ba74547e355 100644 --- a/homeassistant/components/hlk_sw16/strings.json +++ b/homeassistant/components/hlk_sw16/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Hi-Link HLK-SW-16 device." } } }, diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 091f0c18232..8afd3aaf8ce 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -7,7 +7,11 @@ }, "abort": { "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]" + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" @@ -22,22 +26,13 @@ "name": "Device ID", "description": "Id of the device." }, - "program": { - "name": "Program", - "description": "Program to select." - }, - "key": { - "name": "Option key", - "description": "Key of the option." - }, + "program": { "name": "Program", "description": "Program to select." }, + "key": { "name": "Option key", "description": "Key of the option." }, "value": { "name": "Option value", "description": "Value of the option." }, - "unit": { - "name": "Option unit", - "description": "Unit for the option." - } + "unit": { "name": "Option unit", "description": "Unit for the option." } } }, "select_program": { @@ -130,14 +125,8 @@ "name": "Device ID", "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" }, - "key": { - "name": "Key", - "description": "Key of the setting." - }, - "value": { - "name": "Value", - "description": "Value of the setting." - } + "key": { "name": "Key", "description": "Key of the setting." }, + "value": { "name": "Value", "description": "Value of the setting." } } } } diff --git a/homeassistant/components/home_plus_control/strings.json b/homeassistant/components/home_plus_control/strings.json index 280a92055bd..13a7102827c 100644 --- a/homeassistant/components/home_plus_control/strings.json +++ b/homeassistant/components/home_plus_control/strings.json @@ -11,7 +11,11 @@ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 07f14e7ce8c..16a7ee5009c 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -17,6 +17,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import get_device_class from homeassistant.helpers.storage import Store +from homeassistant.util.read_only_dict import ReadOnlyDict from .const import DATA_EXPOSED_ENTITIES, DOMAIN @@ -145,7 +146,7 @@ class ExposedEntities: assistant, entity_id, key, value ) - assistant_options: Mapping[str, Any] + assistant_options: ReadOnlyDict[str, Any] | dict[str, Any] if ( assistant_options := registry_entry.options.get(assistant, {}) ) and assistant_options.get(key) == value: @@ -256,7 +257,8 @@ class ExposedEntities: else: should_expose = False - assistant_options: Mapping[str, Any] = registry_entry.options.get(assistant, {}) + assistant_options: ReadOnlyDict[str, Any] | dict[str, Any] + assistant_options = registry_entry.options.get(assistant, {}) assistant_options = assistant_options | {"should_expose": should_expose} entity_registry.async_update_entity_options( entity_id, assistant, assistant_options diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py index 4b694d2b97a..3308083f22f 100644 --- a/homeassistant/components/homeassistant/scene.py +++ b/homeassistant/components/homeassistant/scene.py @@ -29,14 +29,17 @@ from homeassistant.core import ( State, callback, ) -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import ( config_per_platform, config_validation as cv, entity_platform, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback, EntityPlatform -from homeassistant.helpers.service import async_register_admin_service +from homeassistant.helpers.service import ( + async_extract_entity_ids, + async_register_admin_service, +) from homeassistant.helpers.state import async_reproduce_state from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.loader import async_get_integration @@ -125,6 +128,7 @@ CREATE_SCENE_SCHEMA = vol.All( SERVICE_APPLY = "apply" SERVICE_CREATE = "create" +SERVICE_DELETE = "delete" _LOGGER = logging.getLogger(__name__) @@ -194,7 +198,9 @@ async def async_setup_platform( integration = await async_get_integration(hass, SCENE_DOMAIN) - conf = await conf_util.async_process_component_config(hass, config, integration) + conf = await conf_util.async_process_component_and_handle_errors( + hass, config, integration + ) if not (conf and platform): return @@ -271,6 +277,41 @@ async def async_setup_platform( SCENE_DOMAIN, SERVICE_CREATE, create_service, CREATE_SCENE_SCHEMA ) + async def delete_service(call: ServiceCall) -> None: + """Delete a dynamically created scene.""" + entity_ids = await async_extract_entity_ids(hass, call) + + for entity_id in entity_ids: + scene = platform.entities.get(entity_id) + if scene is None: + raise ServiceValidationError( + f"{entity_id} is not a valid scene entity_id", + translation_domain=SCENE_DOMAIN, + translation_key="entity_not_scene", + translation_placeholders={ + "entity_id": entity_id, + }, + ) + assert isinstance(scene, HomeAssistantScene) + if not scene.from_service: + raise ServiceValidationError( + f"The scene {entity_id} is not created with service `scene.create`", + translation_domain=SCENE_DOMAIN, + translation_key="entity_not_dynamically_created", + translation_placeholders={ + "entity_id": entity_id, + }, + ) + + await platform.async_remove_entity(entity_id) + + hass.services.async_register( + SCENE_DOMAIN, + SERVICE_DELETE, + delete_service, + cv.make_entity_service_schema({}), + ) + def _process_scenes_config( hass: HomeAssistant, async_add_entities: AddEntitiesCallback, config: dict[str, Any] diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 26871522819..6981bdfe685 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -136,5 +136,40 @@ "name": "Reload all", "description": "Reload all YAML configuration that can be reloaded without restarting Home Assistant." } + }, + "exceptions": { + "component_import_err": { + "message": "Unable to import {domain}: {error}" + }, + "config_platform_import_err": { + "message": "Error importing config platform {domain}: {error}" + }, + "config_validation_err": { + "message": "Invalid config for integration {domain} at {config_file}, line {line}: {error}. Check the logs for more information." + }, + "config_validator_unknown_err": { + "message": "Unknown error calling {domain} config validator. Check the logs for more information." + }, + "config_schema_unknown_err": { + "message": "Unknown error calling {domain} CONFIG_SCHEMA. Check the logs for more information." + }, + "integration_config_error": { + "message": "Failed to process config for integration {domain} due to multiple ({errors}) errors. Check the logs for more information." + }, + "platform_component_load_err": { + "message": "Platform error: {domain} - {error}. Check the logs for more information." + }, + "platform_component_load_exc": { + "message": "Platform error: {domain} - {error}. Check the logs for more information." + }, + "platform_config_validation_err": { + "message": "Invalid config for {domain} from integration {p_name} at file {config_file}, line {line}: {error}. Check the logs for more information." + }, + "platform_schema_validator_err": { + "message": "Unknown error when validating config for {domain} from integration {p_name}" + }, + "service_not_found": { + "message": "Service {domain}.{service} not found." + } } } diff --git a/homeassistant/components/homeassistant/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py index a4266a70add..be514fd24ad 100644 --- a/homeassistant/components/homeassistant/triggers/event.py +++ b/homeassistant/components/homeassistant/triggers/event.py @@ -142,7 +142,9 @@ async def async_attach_trigger( ) removes = [ - hass.bus.async_listen(event_type, handle_event, event_filter=filter_event) + hass.bus.async_listen( + event_type, handle_event, event_filter=filter_event, run_immediately=True + ) for event_type in event_types ] diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index 9b27653e4cf..d371998aaf8 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -124,12 +124,15 @@ class Fan(HomeAccessory): ), ) + setter_callback = ( + lambda value, preset_mode=preset_mode: self.set_preset_mode( + value, preset_mode + ) + ) self.preset_mode_chars[preset_mode] = preset_serv.configure_char( CHAR_ON, value=False, - setter_callback=lambda value, preset_mode=preset_mode: self.set_preset_mode( - value, preset_mode - ), + setter_callback=setter_callback, ) if CHAR_SWING_MODE in self.chars: diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index bc61b6fd42e..998c375aac1 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -12,7 +12,7 @@ }, "pair": { "title": "Pair with a device via HomeKit Accessory Protocol", - "description": "HomeKit Device communicates with {name} ({category}) over the local area network using a secure encrypted connection without a separate HomeKit Controller or iCloud. Enter your HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory. This code is usually found on the device itself or in the packaging.", + "description": "HomeKit Device communicates with {name} ({category}) over the local area network using a secure encrypted connection without a separate HomeKit Controller or iCloud. Enter your eight digit HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory. This code is usually found on the device itself or in the packaging, often close to a HomeKit bar code, next to the image of a small house.", "data": { "pairing_code": "Pairing Code", "allow_insecure_setup_codes": "Allow pairing with insecure setup codes." diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index c3d14b7d383..d75ca02b66f 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["homematicip"], "quality_scale": "silver", - "requirements": ["homematicip==1.0.15"] + "requirements": ["homematicip==1.0.16"] } diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index 01705d66f50..036f6c077da 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -1,6 +1,5 @@ """The Homewizard integration.""" from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -10,7 +9,7 @@ from .coordinator import HWEnergyDeviceUpdateCoordinator as Coordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Homewizard from a config entry.""" - coordinator = Coordinator(hass, entry, entry.data[CONF_IP_ADDRESS]) + coordinator = Coordinator(hass) try: await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/homewizard/button.py b/homeassistant/components/homewizard/button.py index 19ffb1d6042..8a6936ee1c8 100644 --- a/homeassistant/components/homewizard/button.py +++ b/homeassistant/components/homewizard/button.py @@ -18,7 +18,7 @@ async def async_setup_entry( """Set up the Identify button.""" coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] if coordinator.supports_identify(): - async_add_entities([HomeWizardIdentifyButton(coordinator, entry)]) + async_add_entities([HomeWizardIdentifyButton(coordinator)]) class HomeWizardIdentifyButton(HomeWizardEntity, ButtonEntity): @@ -27,14 +27,10 @@ class HomeWizardIdentifyButton(HomeWizardEntity, ButtonEntity): _attr_entity_category = EntityCategory.CONFIG _attr_device_class = ButtonDeviceClass.IDENTIFY - def __init__( - self, - coordinator: HWEnergyDeviceUpdateCoordinator, - entry: ConfigEntry, - ) -> None: + def __init__(self, coordinator: HWEnergyDeviceUpdateCoordinator) -> None: """Initialize button.""" super().__init__(coordinator) - self._attr_unique_id = f"{entry.unique_id}_identify" + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_identify" @homewizard_exception_handler async def async_press(self) -> None: diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py index fb89989b2a5..e38b1d54471 100644 --- a/homeassistant/components/homewizard/coordinator.py +++ b/homeassistant/components/homewizard/coordinator.py @@ -9,6 +9,7 @@ from homewizard_energy.errors import DisabledError, RequestError, UnsupportedErr from homewizard_energy.models import Device from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -26,16 +27,18 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] _unsupported_error: bool = False + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, - host: str, ) -> None: """Initialize update coordinator.""" super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) - self.entry = entry - self.api = HomeWizardEnergy(host, clientsession=async_get_clientsession(hass)) + self.api = HomeWizardEnergy( + self.config_entry.data[CONF_IP_ADDRESS], + clientsession=async_get_clientsession(hass), + ) async def _async_update_data(self) -> DeviceResponseEntry: """Fetch all device and sensor data from api.""" @@ -58,7 +61,7 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] self._unsupported_error = True _LOGGER.warning( "%s is running an outdated firmware version (%s). Contact HomeWizard support to update your device", - self.entry.title, + self.config_entry.title, ex, ) @@ -71,7 +74,9 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] # Do not reload when performing first refresh if self.data is not None: - await self.hass.config_entries.async_reload(self.entry.entry_id) + await self.hass.config_entries.async_reload( + self.config_entry.entry_id + ) raise UpdateFailed(ex) from ex diff --git a/homeassistant/components/homewizard/entity.py b/homeassistant/components/homewizard/entity.py index 61bf20dbbc4..2090cc363ba 100644 --- a/homeassistant/components/homewizard/entity.py +++ b/homeassistant/components/homewizard/entity.py @@ -16,7 +16,7 @@ class HomeWizardEntity(CoordinatorEntity[HWEnergyDeviceUpdateCoordinator]): def __init__(self, coordinator: HWEnergyDeviceUpdateCoordinator) -> None: """Initialize the HomeWizard entity.""" - super().__init__(coordinator=coordinator) + super().__init__(coordinator) self._attr_device_info = DeviceInfo( manufacturer="HomeWizard", sw_version=coordinator.data.device.firmware_version, diff --git a/homeassistant/components/homewizard/helpers.py b/homeassistant/components/homewizard/helpers.py index d2d1b7c0119..4f12a4f9726 100644 --- a/homeassistant/components/homewizard/helpers.py +++ b/homeassistant/components/homewizard/helpers.py @@ -8,6 +8,7 @@ from homewizard_energy.errors import DisabledError, RequestError from homeassistant.exceptions import HomeAssistantError +from .const import DOMAIN from .entity import HomeWizardEntity _HomeWizardEntityT = TypeVar("_HomeWizardEntityT", bound=HomeWizardEntity) @@ -30,9 +31,19 @@ def homewizard_exception_handler( try: await func(self, *args, **kwargs) except RequestError as ex: - raise HomeAssistantError from ex + raise HomeAssistantError( + "An error occurred while communicating with HomeWizard device", + translation_domain=DOMAIN, + translation_key="communication_error", + ) from ex except DisabledError as ex: - await self.hass.config_entries.async_reload(self.coordinator.entry.entry_id) - raise HomeAssistantError from ex + await self.hass.config_entries.async_reload( + self.coordinator.config_entry.entry_id + ) + raise HomeAssistantError( + "The local API of the HomeWizard device is disabled", + translation_domain=DOMAIN, + translation_key="api_disabled", + ) from ex return handler diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 96507cb26e4..949dda2a8aa 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==2.1.2"], + "requirements": ["python-homewizard-energy==4.1.0"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py index 07f6bb9b55f..58e0b02a06c 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -21,7 +21,7 @@ async def async_setup_entry( """Set up numbers for device.""" coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] if coordinator.supports_state(): - async_add_entities([HWEnergyNumberEntity(coordinator, entry)]) + async_add_entities([HWEnergyNumberEntity(coordinator)]) class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): @@ -35,11 +35,12 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): def __init__( self, coordinator: HWEnergyDeviceUpdateCoordinator, - entry: ConfigEntry, ) -> None: """Initialize the control number.""" super().__init__(coordinator) - self._attr_unique_id = f"{entry.unique_id}_status_light_brightness" + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}_status_light_brightness" + ) @homewizard_exception_handler async def async_set_native_value(self, value: float) -> None: diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index d8cc72ce45e..78cee9ee6fe 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -26,6 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import DOMAIN from .coordinator import HWEnergyDeviceUpdateCoordinator @@ -34,21 +35,13 @@ from .entity import HomeWizardEntity PARALLEL_UPDATES = 1 -@dataclass -class HomeWizardEntityDescriptionMixin: - """Mixin values for HomeWizard entities.""" - - has_fn: Callable[[Data], bool] - value_fn: Callable[[Data], float | int | str | None] - - -@dataclass -class HomeWizardSensorEntityDescription( - SensorEntityDescription, HomeWizardEntityDescriptionMixin -): +@dataclass(kw_only=True) +class HomeWizardSensorEntityDescription(SensorEntityDescription): """Class describing HomeWizard sensor entities.""" enabled_fn: Callable[[Data], bool] = lambda data: True + has_fn: Callable[[Data], bool] + value_fn: Callable[[Data], StateType] SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( @@ -108,98 +101,106 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="total_power_import_kwh", - translation_key="total_power_import_kwh", + translation_key="total_energy_import_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_power_import_kwh is not None, - value_fn=lambda data: data.total_power_import_kwh or None, + has_fn=lambda data: data.total_energy_import_kwh is not None, + value_fn=lambda data: data.total_energy_import_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t1_kwh", - translation_key="total_power_import_t1_kwh", + translation_key="total_energy_import_t1_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_power_import_t1_kwh is not None, - value_fn=lambda data: data.total_power_import_t1_kwh or None, + has_fn=lambda data: ( + # SKT/SDM230/630 provides both total and tariff 1: duplicate. + data.total_energy_import_t1_kwh is not None + and data.total_energy_export_t2_kwh is not None + ), + value_fn=lambda data: data.total_energy_import_t1_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t2_kwh", - translation_key="total_power_import_t2_kwh", + translation_key="total_energy_import_t2_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_power_import_t2_kwh is not None, - value_fn=lambda data: data.total_power_import_t2_kwh or None, + has_fn=lambda data: data.total_energy_import_t2_kwh is not None, + value_fn=lambda data: data.total_energy_import_t2_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t3_kwh", - translation_key="total_power_import_t3_kwh", + translation_key="total_energy_import_t3_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_power_import_t3_kwh is not None, - value_fn=lambda data: data.total_power_import_t3_kwh or None, + has_fn=lambda data: data.total_energy_import_t3_kwh is not None, + value_fn=lambda data: data.total_energy_import_t3_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t4_kwh", - translation_key="total_power_import_t4_kwh", + translation_key="total_energy_import_t4_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_power_import_t4_kwh is not None, - value_fn=lambda data: data.total_power_import_t4_kwh or None, + has_fn=lambda data: data.total_energy_import_t4_kwh is not None, + value_fn=lambda data: data.total_energy_import_t4_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_kwh", - translation_key="total_power_export_kwh", + translation_key="total_energy_export_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_power_export_kwh is not None, - enabled_fn=lambda data: data.total_power_export_kwh != 0, - value_fn=lambda data: data.total_power_export_kwh or None, + has_fn=lambda data: data.total_energy_export_kwh is not None, + enabled_fn=lambda data: data.total_energy_export_kwh != 0, + value_fn=lambda data: data.total_energy_export_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t1_kwh", - translation_key="total_power_export_t1_kwh", + translation_key="total_energy_export_t1_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_power_export_t1_kwh is not None, - enabled_fn=lambda data: data.total_power_export_t1_kwh != 0, - value_fn=lambda data: data.total_power_export_t1_kwh or None, + has_fn=lambda data: ( + # SKT/SDM230/630 provides both total and tariff 1: duplicate. + data.total_energy_export_t1_kwh is not None + and data.total_energy_export_t2_kwh is not None + ), + enabled_fn=lambda data: data.total_energy_export_t1_kwh != 0, + value_fn=lambda data: data.total_energy_export_t1_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t2_kwh", - translation_key="total_power_export_t2_kwh", + translation_key="total_energy_export_t2_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_power_export_t2_kwh is not None, - enabled_fn=lambda data: data.total_power_export_t2_kwh != 0, - value_fn=lambda data: data.total_power_export_t2_kwh or None, + has_fn=lambda data: data.total_energy_export_t2_kwh is not None, + enabled_fn=lambda data: data.total_energy_export_t2_kwh != 0, + value_fn=lambda data: data.total_energy_export_t2_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t3_kwh", - translation_key="total_power_export_t3_kwh", + translation_key="total_energy_export_t3_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_power_export_t3_kwh is not None, - enabled_fn=lambda data: data.total_power_export_t3_kwh != 0, - value_fn=lambda data: data.total_power_export_t3_kwh or None, + has_fn=lambda data: data.total_energy_export_t3_kwh is not None, + enabled_fn=lambda data: data.total_energy_export_t3_kwh != 0, + value_fn=lambda data: data.total_energy_export_t3_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t4_kwh", - translation_key="total_power_export_t4_kwh", + translation_key="total_energy_export_t4_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_power_export_t4_kwh is not None, - enabled_fn=lambda data: data.total_power_export_t4_kwh != 0, - value_fn=lambda data: data.total_power_export_t4_kwh or None, + has_fn=lambda data: data.total_energy_export_t4_kwh is not None, + enabled_fn=lambda data: data.total_energy_export_t4_kwh != 0, + value_fn=lambda data: data.total_energy_export_t4_kwh, ), HomeWizardSensorEntityDescription( key="active_power_w", @@ -207,6 +208,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, has_fn=lambda data: data.active_power_w is not None, value_fn=lambda data: data.active_power_w, ), @@ -216,6 +218,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, has_fn=lambda data: data.active_power_l1_w is not None, value_fn=lambda data: data.active_power_l1_w, ), @@ -225,6 +228,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, has_fn=lambda data: data.active_power_l2_w is not None, value_fn=lambda data: data.active_power_l2_w, ), @@ -234,6 +238,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, has_fn=lambda data: data.active_power_l3_w is not None, value_fn=lambda data: data.active_power_l3_w, ), @@ -394,7 +399,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.GAS, state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: data.total_gas_m3 is not None, - value_fn=lambda data: data.total_gas_m3 or None, + value_fn=lambda data: data.total_gas_m3, ), HomeWizardSensorEntityDescription( key="gas_unique_id", @@ -421,7 +426,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.WATER, state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: data.total_liter_m3 is not None, - value_fn=lambda data: data.total_liter_m3 or None, + value_fn=lambda data: data.total_liter_m3, ), ) @@ -433,7 +438,7 @@ async def async_setup_entry( coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - HomeWizardSensorEntity(coordinator, entry, description) + HomeWizardSensorEntity(coordinator, description) for description in SENSORS if description.has_fn(coordinator.data.data) ) @@ -447,18 +452,17 @@ class HomeWizardSensorEntity(HomeWizardEntity, SensorEntity): def __init__( self, coordinator: HWEnergyDeviceUpdateCoordinator, - entry: ConfigEntry, description: HomeWizardSensorEntityDescription, ) -> None: """Initialize Sensor Domain.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{entry.unique_id}_{description.key}" + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" if not description.enabled_fn(self.coordinator.data.data): self._attr_entity_registry_enabled_default = False @property - def native_value(self) -> float | int | str | None: + def native_value(self) -> StateType: """Return the sensor value.""" return self.entity_description.value_fn(self.coordinator.data.data) diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 7bb4b16c710..acdb321d6ff 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -53,35 +53,35 @@ "wifi_strength": { "name": "Wi-Fi strength" }, - "total_power_import_kwh": { - "name": "Total power import" + "total_energy_import_kwh": { + "name": "Total energy import" }, - "total_power_import_t1_kwh": { - "name": "Total power import tariff 1" + "total_energy_import_t1_kwh": { + "name": "Total energy import tariff 1" }, - "total_power_import_t2_kwh": { - "name": "Total power import tariff 2" + "total_energy_import_t2_kwh": { + "name": "Total energy import tariff 2" }, - "total_power_import_t3_kwh": { - "name": "Total power import tariff 3" + "total_energy_import_t3_kwh": { + "name": "Total energy import tariff 3" }, - "total_power_import_t4_kwh": { - "name": "Total power import tariff 4" + "total_energy_import_t4_kwh": { + "name": "Total energy import tariff 4" }, - "total_power_export_kwh": { - "name": "Total power export" + "total_energy_export_kwh": { + "name": "Total energy export" }, - "total_power_export_t1_kwh": { - "name": "Total power export tariff 1" + "total_energy_export_t1_kwh": { + "name": "Total energy export tariff 1" }, - "total_power_export_t2_kwh": { - "name": "Total power export tariff 2" + "total_energy_export_t2_kwh": { + "name": "Total energy export tariff 2" }, - "total_power_export_t3_kwh": { - "name": "Total power export tariff 3" + "total_energy_export_t3_kwh": { + "name": "Total energy export tariff 3" }, - "total_power_export_t4_kwh": { - "name": "Total power export tariff 4" + "total_energy_export_t4_kwh": { + "name": "Total energy export tariff 4" }, "active_power_w": { "name": "Active power" @@ -167,5 +167,13 @@ "name": "Cloud connection" } } + }, + "exceptions": { + "api_disabled": { + "message": "The local API of the HomeWizard device is disabled" + }, + "communication_error": { + "message": "An error occurred while communicating with HomeWizard device" + } } } diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py index cddcabc841e..3f854aad320 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -23,23 +23,15 @@ from .entity import HomeWizardEntity from .helpers import homewizard_exception_handler -@dataclass -class HomeWizardEntityDescriptionMixin: - """Mixin values for HomeWizard entities.""" - - create_fn: Callable[[HWEnergyDeviceUpdateCoordinator], bool] - available_fn: Callable[[DeviceResponseEntry], bool] - is_on_fn: Callable[[DeviceResponseEntry], bool | None] - set_fn: Callable[[HomeWizardEnergy, bool], Awaitable[Any]] - - -@dataclass -class HomeWizardSwitchEntityDescription( - SwitchEntityDescription, HomeWizardEntityDescriptionMixin -): +@dataclass(kw_only=True) +class HomeWizardSwitchEntityDescription(SwitchEntityDescription): """Class describing HomeWizard switch entities.""" + available_fn: Callable[[DeviceResponseEntry], bool] + create_fn: Callable[[HWEnergyDeviceUpdateCoordinator], bool] icon_off: str | None = None + is_on_fn: Callable[[DeviceResponseEntry], bool | None] + set_fn: Callable[[HomeWizardEnergy, bool], Awaitable[Any]] SWITCHES = [ @@ -86,11 +78,7 @@ async def async_setup_entry( coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - HomeWizardSwitchEntity( - coordinator=coordinator, - description=description, - entry=entry, - ) + HomeWizardSwitchEntity(coordinator, description) for description in SWITCHES if description.create_fn(coordinator) ) @@ -105,12 +93,11 @@ class HomeWizardSwitchEntity(HomeWizardEntity, SwitchEntity): self, coordinator: HWEnergyDeviceUpdateCoordinator, description: HomeWizardSwitchEntityDescription, - entry: ConfigEntry, ) -> None: """Initialize the switch.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{entry.unique_id}_{description.key}" + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" @property def icon(self) -> str | None: diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index e9af4b2fd95..dfac69b3aed 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -6,7 +6,13 @@ import datetime from typing import Any from aiohttp import ClientConnectionError -from aiosomecomfort import SomeComfortError, UnauthorizedError, UnexpectedResponse +from aiosomecomfort import ( + AuthError, + ConnectionError as AscConnectionError, + SomeComfortError, + UnauthorizedError, + UnexpectedResponse, +) from aiosomecomfort.device import Device as SomeComfortDevice from homeassistant.components.climate import ( @@ -492,31 +498,38 @@ class HoneywellUSThermostat(ClimateEntity): async def async_update(self) -> None: """Get the latest state from the service.""" - try: - await self._device.refresh() - self._attr_available = True - self._retry = 0 - except UnauthorizedError: + async def _login() -> None: try: await self._data.client.login() await self._device.refresh() - self._attr_available = True - self._retry = 0 except ( - SomeComfortError, + AuthError, ClientConnectionError, asyncio.TimeoutError, ): self._retry += 1 - if self._retry > RETRY: - self._attr_available = False + self._attr_available = self._retry <= RETRY + return - except (ClientConnectionError, asyncio.TimeoutError): + self._attr_available = True + self._retry = 0 + + try: + await self._device.refresh() + + except UnauthorizedError: + await _login() + return + + except (AscConnectionError, ClientConnectionError, asyncio.TimeoutError): self._retry += 1 - if self._retry > RETRY: - self._attr_available = False + self._attr_available = self._retry <= RETRY + return except UnexpectedResponse: - pass + return + + self._attr_available = True + self._retry = 0 diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index 47213476ad9..c4ddba49357 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.22"] + "requirements": ["AIOSomecomfort==0.0.24"] } diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 122b7b79ce9..449f00fb335 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -16,13 +16,9 @@ from aiohttp.http_parser import RawRequestMessage from aiohttp.streams import StreamReader from aiohttp.typedefs import JSONDecoder, StrOrURL from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection -from aiohttp.web_log import AccessLogger from aiohttp.web_protocol import RequestHandler -from aiohttp.web_urldispatcher import ( - AbstractResource, - UrlDispatcher, - UrlMappingMatchInfo, -) +from aiohttp_fast_url_dispatcher import FastUrlDispatcher, attach_fast_url_dispatcher +from aiohttp_zlib_ng import enable_zlib_ng from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa @@ -173,6 +169,8 @@ class ApiConfig: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HTTP API and debug interface.""" + enable_zlib_ng() + conf: ConfData | None = config.get(DOMAIN) if conf is None: @@ -239,25 +237,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class HomeAssistantAccessLogger(AccessLogger): - """Access logger for Home Assistant that does not log when disabled.""" - - def log( - self, request: web.BaseRequest, response: web.StreamResponse, time: float - ) -> None: - """Log the request. - - The default implementation logs the request to the logger - with the INFO level and than throws it away if the logger - is not enabled for the INFO level. This implementation - does not log the request if the logger is not enabled for - the INFO level. - """ - if not self.logger.isEnabledFor(logging.INFO): - return - super().log(request, response, time) - - class HomeAssistantRequest(web.Request): """Home Assistant request object.""" @@ -318,7 +297,7 @@ class HomeAssistantHTTP: # By default aiohttp does a linear search for routing rules, # we have a lot of routes, so use a dict lookup with a fallback # to the linear search. - self.app._router = FastUrlDispatcher() + attach_fast_url_dispatcher(self.app, FastUrlDispatcher()) self.hass = hass self.ssl_certificate = ssl_certificate self.ssl_peer_certificate = ssl_peer_certificate @@ -541,9 +520,7 @@ class HomeAssistantHTTP: # pylint: disable-next=protected-access self.app._router.freeze = lambda: None # type: ignore[method-assign] - self.runner = web.AppRunner( - self.app, access_log_class=HomeAssistantAccessLogger - ) + self.runner = web.AppRunner(self.app, handler_cancellation=True) await self.runner.setup() self.site = HomeAssistantTCPSite( @@ -584,40 +561,3 @@ async def start_http_server_and_save_config( ] store.async_delay_save(lambda: conf, SAVE_DELAY) - - -class FastUrlDispatcher(UrlDispatcher): - """UrlDispatcher that uses a dict lookup for resolving.""" - - def __init__(self) -> None: - """Initialize the dispatcher.""" - super().__init__() - self._resource_index: dict[str, list[AbstractResource]] = {} - - def register_resource(self, resource: AbstractResource) -> None: - """Register a resource.""" - super().register_resource(resource) - canonical = resource.canonical - if "{" in canonical: # strip at the first { to allow for variables - canonical = canonical.split("{")[0].rstrip("/") - # There may be multiple resources for a canonical path - # so we use a list to avoid falling back to a full linear search - self._resource_index.setdefault(canonical, []).append(resource) - - async def resolve(self, request: web.Request) -> UrlMappingMatchInfo: - """Resolve a request.""" - url_parts = request.rel_url.raw_parts - resource_index = self._resource_index - - # Walk the url parts looking for candidates - for i in range(len(url_parts), 0, -1): - url_part = "/" + "/".join(url_parts[1:i]) - if (resource_candidates := resource_index.get(url_part)) is not None: - for candidate in resource_candidates: - if ( - match_dict := (await candidate.resolve(request))[0] - ) is not None: - return match_dict - - # Finally, fallback to the linear search - return await super().resolve(request) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index fc7b3c03abe..618bab91f7f 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -21,6 +21,7 @@ from homeassistant.auth.models import User from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.json import json_bytes +from homeassistant.helpers.network import is_cloud_connection from homeassistant.helpers.storage import Store from homeassistant.util.network import is_local @@ -98,12 +99,8 @@ def async_user_not_allowed_do_auth( if not request: return "No request available to validate local access" - if "cloud" in hass.config.components: - # pylint: disable-next=import-outside-toplevel - from hass_nabucasa import remote - - if remote.is_cloud_request.get(): - return "User is local only" + if is_cloud_connection(hass): + return "User is local only" try: remote_address = ip_address(request.remote) # type: ignore[arg-type] diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 0fa3e95eaf2..c56dd6c343b 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -162,27 +162,28 @@ async def process_wrong_login(request: Request) -> None: ) -async def process_success_login(request: Request) -> None: +@callback +def process_success_login(request: Request) -> None: """Process a success login attempt. Reset failed login attempts counter for remote IP address. No release IP address from banned list function, it can only be done by manual modify ip bans config file. """ - remote_addr = ip_address(request.remote) # type: ignore[arg-type] - + app = request.app # Check if ban middleware is loaded - if KEY_BAN_MANAGER not in request.app or request.app[KEY_LOGIN_THRESHOLD] < 1: + if KEY_BAN_MANAGER not in app or app[KEY_LOGIN_THRESHOLD] < 1: return - if ( - remote_addr in request.app[KEY_FAILED_LOGIN_ATTEMPTS] - and request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] > 0 - ): + remote_addr = ip_address(request.remote) # type: ignore[arg-type] + login_attempt_history: defaultdict[IPv4Address | IPv6Address, int] = app[ + KEY_FAILED_LOGIN_ATTEMPTS + ] + if remote_addr in login_attempt_history and login_attempt_history[remote_addr] > 0: _LOGGER.debug( "Login success, reset failed login attempts counter from %s", remote_addr ) - request.app[KEY_FAILED_LOGIN_ATTEMPTS].pop(remote_addr) + login_attempt_history.pop(remote_addr) class IpBan: diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json index bce425adbdb..c68ecd79d5f 100644 --- a/homeassistant/components/http/manifest.json +++ b/homeassistant/components/http/manifest.json @@ -6,5 +6,9 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["aiohttp_cors==0.7.0"] + "requirements": [ + "aiohttp_cors==0.7.0", + "aiohttp-fast-url-dispatcher==0.3.0", + "aiohttp-zlib-ng==0.1.1" + ] } diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index 6cb1bafdaca..1ab4ef5bd6f 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -1,7 +1,8 @@ """Static file handling for HTTP component.""" from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Mapping, MutableMapping +import mimetypes from pathlib import Path from typing import Final @@ -16,16 +17,22 @@ from homeassistant.core import HomeAssistant from .const import KEY_HASS CACHE_TIME: Final = 31 * 86400 # = 1 month -CACHE_HEADERS: Final[Mapping[str, str]] = { - hdrs.CACHE_CONTROL: f"public, max-age={CACHE_TIME}" -} -PATH_CACHE = LRU(512) +CACHE_HEADER = f"public, max-age={CACHE_TIME}" +CACHE_HEADERS: Mapping[str, str] = {hdrs.CACHE_CONTROL: CACHE_HEADER} +PATH_CACHE: MutableMapping[ + tuple[str, Path, bool], tuple[Path | None, str | None] +] = LRU(512) -def _get_file_path( - filename: str | Path, directory: Path, follow_symlinks: bool -) -> Path | None: - filepath = directory.joinpath(filename).resolve() +def _get_file_path(rel_url: str, directory: Path, follow_symlinks: bool) -> Path | None: + """Return the path to file on disk or None.""" + filename = Path(rel_url) + if filename.anchor: + # rel_url is an absolute name like + # /static/\\machine_name\c$ or /static/D:\path + # where the static dir is totally different + raise HTTPForbidden + filepath: Path = directory.joinpath(filename).resolve() if not follow_symlinks: filepath.relative_to(directory) # on opening a dir, load its contents if allowed @@ -40,32 +47,41 @@ class CachingStaticResource(StaticResource): """Static Resource handler that will add cache headers.""" async def _handle(self, request: Request) -> StreamResponse: + """Return requested file from disk as a FileResponse.""" rel_url = request.match_info["filename"] - hass: HomeAssistant = request.app[KEY_HASS] - filename = Path(rel_url) - if filename.anchor: - # rel_url is an absolute name like - # /static/\\machine_name\c$ or /static/D:\path - # where the static dir is totally different - raise HTTPForbidden() - try: - key = (filename, self._directory, self._follow_symlinks) - if (filepath := PATH_CACHE.get(key)) is None: - filepath = PATH_CACHE[key] = await hass.async_add_executor_job( - _get_file_path, filename, self._directory, self._follow_symlinks - ) - except (ValueError, FileNotFoundError) as error: - # relatively safe - raise HTTPNotFound() from error - except Exception as error: - # perm error or other kind! - request.app.logger.exception(error) - raise HTTPNotFound() from error + key = (rel_url, self._directory, self._follow_symlinks) + if (filepath_content_type := PATH_CACHE.get(key)) is None: + hass: HomeAssistant = request.app[KEY_HASS] + try: + filepath = await hass.async_add_executor_job(_get_file_path, *key) + except (ValueError, FileNotFoundError) as error: + # relatively safe + raise HTTPNotFound() from error + except HTTPForbidden: + # forbidden + raise + except Exception as error: + # perm error or other kind! + request.app.logger.exception(error) + raise HTTPNotFound() from error - if filepath: + content_type: str | None = None + if filepath is not None: + content_type = (mimetypes.guess_type(rel_url))[ + 0 + ] or "application/octet-stream" + PATH_CACHE[key] = (filepath, content_type) + else: + filepath, content_type = filepath_content_type + + if filepath and content_type: return FileResponse( filepath, chunk_size=self._chunk_size, - headers=CACHE_HEADERS, + headers={ + hdrs.CACHE_CONTROL: CACHE_HEADER, + hdrs.CONTENT_TYPE: content_type, + }, ) + return await super()._handle(request) diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index abdcfe466c1..1be3d761a3b 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -71,6 +71,7 @@ class HomeAssistantView: content_type=CONTENT_TYPE_JSON, status=int(status_code), headers=headers, + zlib_executor_size=32768, ) response.enable_compression() return response diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 929ca0193af..d8c939e5c3a 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -35,6 +35,7 @@ from homeassistant.const import ( CONF_RECIPIENT, CONF_URL, CONF_USERNAME, + CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP, Platform, ) @@ -50,6 +51,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType @@ -57,6 +59,8 @@ from .const import ( ADMIN_SERVICES, ALL_KEYS, ATTR_CONFIG_ENTRY_ID, + BUTTON_KEY_CLEAR_TRAFFIC_STATISTICS, + BUTTON_KEY_RESTART, CONF_MANUFACTURER, CONF_UNAUTHENTICATED_MODE, CONNECTION_TIMEOUT, @@ -86,7 +90,7 @@ from .const import ( SERVICE_SUSPEND_INTEGRATION, UPDATE_SIGNAL, ) -from .utils import get_device_macs +from .utils import get_device_macs, non_verifying_requests_session _LOGGER = logging.getLogger(__name__) @@ -127,6 +131,7 @@ SERVICE_SCHEMA = vol.Schema({vol.Optional(CONF_URL): cv.url}) PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.SWITCH, @@ -302,10 +307,11 @@ class Router: """Log out router session.""" try: self.client.user.logout() - except ResponseErrorNotSupportedException: - _LOGGER.debug("Logout not supported by device", exc_info=True) - except ResponseErrorLoginRequiredException: - _LOGGER.debug("Logout not supported when not logged in", exc_info=True) + except ( + ResponseErrorLoginRequiredException, + ResponseErrorNotSupportedException, + ): + pass # Ok, normal, nothing to do except Exception: # pylint: disable=broad-except _LOGGER.warning("Logout error", exc_info=True) @@ -331,16 +337,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def _connect() -> Connection: """Set up a connection.""" + kwargs: dict[str, Any] = { + "timeout": CONNECTION_TIMEOUT, + } + if url.startswith("https://") and not entry.data.get(CONF_VERIFY_SSL): + kwargs["requests_session"] = non_verifying_requests_session(url) if entry.options.get(CONF_UNAUTHENTICATED_MODE): _LOGGER.debug("Connecting in unauthenticated mode, reduced feature set") - connection = Connection(url, timeout=CONNECTION_TIMEOUT) + connection = Connection(url, **kwargs) else: _LOGGER.debug("Connecting in authenticated mode, full feature set") username = entry.data.get(CONF_USERNAME) or "" password = entry.data.get(CONF_PASSWORD) or "" - connection = Connection( - url, username=username, password=password, timeout=CONNECTION_TIMEOUT - ) + connection = Connection(url, username=username, password=password, **kwargs) return connection try: @@ -524,12 +533,38 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return if service.service == SERVICE_CLEAR_TRAFFIC_STATISTICS: + create_issue( + hass, + DOMAIN, + "service_clear_traffic_statistics_moved_to_button", + breaks_in_ha_version="2024.2.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="service_changed_to_button", + translation_placeholders={ + "service": service.service, + "button": BUTTON_KEY_CLEAR_TRAFFIC_STATISTICS, + }, + ) if router.suspended: _LOGGER.debug("%s: ignored, integration suspended", service.service) return result = router.client.monitoring.set_clear_traffic() _LOGGER.debug("%s: %s", service.service, result) elif service.service == SERVICE_REBOOT: + create_issue( + hass, + DOMAIN, + "service_reboot_moved_to_button", + breaks_in_ha_version="2024.2.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="service_changed_to_button", + translation_placeholders={ + "service": service.service, + "button": BUTTON_KEY_RESTART, + }, + ) if router.suspended: _LOGGER.debug("%s: ignored, integration suspended", service.service) return diff --git a/homeassistant/components/huawei_lte/button.py b/homeassistant/components/huawei_lte/button.py new file mode 100644 index 00000000000..f494836e80d --- /dev/null +++ b/homeassistant/components/huawei_lte/button.py @@ -0,0 +1,97 @@ +"""Huawei LTE buttons.""" + +from __future__ import annotations + +import logging + +from huawei_lte_api.enums.device import ControlModeEnum + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_platform + +from . import HuaweiLteBaseEntityWithDevice +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: entity_platform.AddEntitiesCallback, +) -> None: + """Set up Huawei LTE buttons.""" + router = hass.data[DOMAIN].routers[config_entry.entry_id] + buttons = [ + ClearTrafficStatisticsButton(router), + RestartButton(router), + ] + async_add_entities(buttons) + + +class BaseButton(HuaweiLteBaseEntityWithDevice, ButtonEntity): + """Huawei LTE button base class.""" + + @property + def _device_unique_id(self) -> str: + """Return unique ID for entity within a router.""" + return f"button-{self.entity_description.key}" + + async def async_update(self) -> None: + """Update is not necessary for button entities.""" + + def press(self) -> None: + """Press button.""" + if self.router.suspended: + _LOGGER.debug( + "%s: ignored, integration suspended", self.entity_description.key + ) + return + result = self._press() + _LOGGER.debug("%s: %s", self.entity_description.key, result) + + def _press(self) -> str: + """Invoke low level action of button press.""" + raise NotImplementedError + + +BUTTON_KEY_CLEAR_TRAFFIC_STATISTICS = "clear_traffic_statistics" + + +class ClearTrafficStatisticsButton(BaseButton): + """Huawei LTE clear traffic statistics button.""" + + entity_description = ButtonEntityDescription( + key=BUTTON_KEY_CLEAR_TRAFFIC_STATISTICS, + name="Clear traffic statistics", + entity_category=EntityCategory.CONFIG, + ) + + def _press(self) -> str: + """Call clear traffic statistics endpoint.""" + return self.router.client.monitoring.set_clear_traffic() + + +BUTTON_KEY_RESTART = "restart" + + +class RestartButton(BaseButton): + """Huawei LTE restart button.""" + + entity_description = ButtonEntityDescription( + key=BUTTON_KEY_RESTART, + name="Restart", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + ) + + def _press(self) -> str: + """Call restart endpoint.""" + return self.router.client.device.set_control(ControlModeEnum.REBOOT) diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 6d7b0b9bb11..c97c8d6367b 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -16,7 +16,7 @@ from huawei_lte_api.exceptions import ( ResponseErrorException, ) from huawei_lte_api.Session import GetResponseType -from requests.exceptions import Timeout +from requests.exceptions import SSLError, Timeout from url_normalize import url_normalize import voluptuous as vol @@ -29,6 +29,7 @@ from homeassistant.const import ( CONF_RECIPIENT, CONF_URL, CONF_USERNAME, + CONF_VERIFY_SSL, ) from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -44,7 +45,7 @@ from .const import ( DEFAULT_UNAUTHENTICATED_MODE, DOMAIN, ) -from .utils import get_device_macs +from .utils import get_device_macs, non_verifying_requests_session _LOGGER = logging.getLogger(__name__) @@ -80,6 +81,13 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.context.get(CONF_URL, ""), ), ): str, + vol.Optional( + CONF_VERIFY_SSL, + default=user_input.get( + CONF_VERIFY_SSL, + False, + ), + ): bool, vol.Optional( CONF_USERNAME, default=user_input.get(CONF_USERNAME) or "" ): str, @@ -119,11 +127,20 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): password = user_input.get(CONF_PASSWORD) or "" def _get_connection() -> Connection: + if ( + user_input[CONF_URL].startswith("https://") + and not user_input[CONF_VERIFY_SSL] + ): + requests_session = non_verifying_requests_session(user_input[CONF_URL]) + else: + requests_session = None + return Connection( url=user_input[CONF_URL], username=username, password=password, timeout=CONNECTION_TIMEOUT, + requests_session=requests_session, ) conn = None @@ -140,6 +157,12 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except ResponseErrorException: _LOGGER.warning("Response error", exc_info=True) errors["base"] = "response_error" + except SSLError: + _LOGGER.warning("SSL error", exc_info=True) + if user_input[CONF_VERIFY_SSL]: + errors[CONF_URL] = "ssl_error_try_unverified" + else: + errors[CONF_URL] = "ssl_error_try_plain" except Timeout: _LOGGER.warning("Connection timeout", exc_info=True) errors[CONF_URL] = "connection_timeout" @@ -152,6 +175,7 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def _disconnect(conn: Connection) -> None: try: conn.close() + conn.requests_session.close() except Exception: # pylint: disable=broad-except _LOGGER.debug("Disconnect error", exc_info=True) diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py index 53cc0efb919..eba0f3ce90b 100644 --- a/homeassistant/components/huawei_lte/const.py +++ b/homeassistant/components/huawei_lte/const.py @@ -79,3 +79,6 @@ ALL_KEYS = ( | SWITCH_KEYS | {KEY_DEVICE_BASIC_INFORMATION} ) + +BUTTON_KEY_CLEAR_TRAFFIC_STATISTICS = "clear_traffic_statistics" +BUTTON_KEY_RESTART = "restart" diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index d563bed4d46..9a44024111c 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["huawei_lte_api.Session"], "requirements": [ - "huawei-lte-api==1.6.11", + "huawei-lte-api==1.7.3", "stringcase==1.2.0", "url-normalize==1.4.3" ], diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 07486297b32..ca3734bb305 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -8,8 +8,6 @@ from datetime import datetime, timedelta import logging import re -from huawei_lte_api.enums.net import NetworkModeEnum - from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, @@ -575,10 +573,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "State": HuaweiSensorEntityDescription( key="State", translation_key="operator_search_mode", - format_fn=lambda x: ( - {"0": "Auto", "1": "Manual"}.get(x), - None, - ), entity_category=EntityCategory.DIAGNOSTIC, ), }, @@ -588,19 +582,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { descriptions={ "NetworkMode": HuaweiSensorEntityDescription( key="NetworkMode", - translation_key="preferred_mode", - format_fn=lambda x: ( - { - NetworkModeEnum.MODE_AUTO.value: "4G/3G/2G", - NetworkModeEnum.MODE_4G_3G_AUTO.value: "4G/3G", - NetworkModeEnum.MODE_4G_2G_AUTO.value: "4G/2G", - NetworkModeEnum.MODE_4G_ONLY.value: "4G", - NetworkModeEnum.MODE_3G_2G_AUTO.value: "3G/2G", - NetworkModeEnum.MODE_3G_ONLY.value: "3G", - NetworkModeEnum.MODE_2G_ONLY.value: "2G", - }.get(x), - None, - ), + translation_key="preferred_network_mode", entity_category=EntityCategory.DIAGNOSTIC, ), }, @@ -718,10 +700,6 @@ class HuaweiLteSensor(HuaweiLteBaseEntityWithDevice, SensorEntity): _unit: str | None = field(default=None, init=False) _last_reset: datetime | None = field(default=None, init=False) - def __post_init__(self) -> None: - """Initialize remaining attributes.""" - self._attr_name = self.entity_description.name or self.item - async def async_added_to_hass(self) -> None: """Subscribe to needed data on add.""" await super().async_added_to_hass() diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index f188eb9e17b..754f192e57e 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -14,6 +14,8 @@ "invalid_url": "Invalid URL", "login_attempts_exceeded": "Maximum login attempts exceeded, please try again later", "response_error": "Unknown error from device", + "ssl_error_try_plain": "HTTPS error, please try a plain HTTP URL", + "ssl_error_try_unverified": "HTTPS error, please try disabling certificate verification or a plain HTTP URL", "unknown": "[%key:common::config_flow::error::unknown%]" }, "flow_title": "{name}", @@ -30,7 +32,8 @@ "data": { "password": "[%key:common::config_flow::data::password%]", "url": "[%key:common::config_flow::data::url%]", - "username": "[%key:common::config_flow::data::username%]" + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "description": "Enter device access details.", "title": "Configure Huawei LTE" @@ -228,10 +231,23 @@ "name": "Operator code" }, "operator_search_mode": { - "name": "Operator search mode" + "name": "Operator search mode", + "state": { + "0": "Auto", + "1": "Manual" + } }, - "preferred_mode": { - "name": "Preferred mode" + "preferred_network_mode": { + "name": "Preferred network mode", + "state": { + "00": "4G/3G/2G auto", + "0302": "4G/3G auto", + "0301": "4G/2G auto", + "03": "4G only", + "0201": "3G/2G auto", + "02": "3G only", + "01": "2G only" + } }, "sms_deleted_device": { "name": "SMS deleted (device)" @@ -279,6 +295,12 @@ } } }, + "issues": { + "service_changed_to_button": { + "title": "Service changed to a button", + "description": "The {service} service is deprecated, use the corresponding {button} button instead." + } + }, "services": { "clear_traffic_statistics": { "name": "Clear traffic statistics", diff --git a/homeassistant/components/huawei_lte/utils.py b/homeassistant/components/huawei_lte/utils.py index 172e8658928..df212a1c25d 100644 --- a/homeassistant/components/huawei_lte/utils.py +++ b/homeassistant/components/huawei_lte/utils.py @@ -2,8 +2,13 @@ from __future__ import annotations from contextlib import suppress +import re +from urllib.parse import urlparse +import warnings from huawei_lte_api.Session import GetResponseType +import requests +from urllib3.exceptions import InsecureRequestWarning from homeassistant.helpers.device_registry import format_mac @@ -25,3 +30,18 @@ def get_device_macs( macs.extend(x.get("WifiMac") for x in wlan_settings["Ssids"]["Ssid"]) return sorted({format_mac(str(x)) for x in macs if x}) + + +def non_verifying_requests_session(url: str) -> requests.Session: + """Get requests.Session that does not verify HTTPS, filter warnings about it.""" + parsed_url = urlparse(url) + assert parsed_url.hostname + requests_session = requests.Session() + requests_session.verify = False + warnings.filterwarnings( + "ignore", + message=rf"^.*\b{re.escape(parsed_url.hostname)}\b", + category=InsecureRequestWarning, + module=r"^urllib3\.connectionpool$", + ) + return requests_session diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 4022c61bc36..114f501d7a3 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -5,12 +5,18 @@ "title": "Pick Hue bridge", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Hue bridge." } }, "manual": { "title": "Manual configure a Hue bridge", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::hue::config::step::init::data_description::host%]" } }, "link": { diff --git a/homeassistant/components/hunterdouglas_powerview/select.py b/homeassistant/components/hunterdouglas_powerview/select.py index 37d1193e0e5..151b3a58011 100644 --- a/homeassistant/components/hunterdouglas_powerview/select.py +++ b/homeassistant/components/hunterdouglas_powerview/select.py @@ -116,5 +116,6 @@ class PowerViewSelect(ShadeEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self.entity_description.select_fn(self._shade, option) - await self._shade.refresh() # force update data to ensure new info is in coordinator + # force update data to ensure new info is in coordinator + await self._shade.refresh() self.async_write_ha_state() diff --git a/homeassistant/components/hvv_departures/binary_sensor.py b/homeassistant/components/hvv_departures/binary_sensor.py index 0ec08e9c791..8337921acf6 100644 --- a/homeassistant/components/hvv_departures/binary_sensor.py +++ b/homeassistant/components/hvv_departures/binary_sensor.py @@ -125,13 +125,29 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity): _attr_attribution = ATTRIBUTION _attr_has_entity_name = True + _attr_device_class = BinarySensorDeviceClass.PROBLEM def __init__(self, coordinator, idx, config_entry): """Initialize.""" super().__init__(coordinator) self.coordinator = coordinator self.idx = idx - self.config_entry = config_entry + + self._attr_name = coordinator.data[idx]["name"] + self._attr_unique_id = idx + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={ + ( + DOMAIN, + config_entry.entry_id, + config_entry.data[CONF_STATION]["id"], + config_entry.data[CONF_STATION]["type"], + ) + }, + manufacturer=MANUFACTURER, + name=f"Departures at {config_entry.data[CONF_STATION]['name']}", + ) @property def is_on(self): @@ -146,38 +162,6 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity): and self.coordinator.data[self.idx]["available"] ) - @property - def device_info(self): - """Return the device info for this sensor.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={ - ( - DOMAIN, - self.config_entry.entry_id, - self.config_entry.data[CONF_STATION]["id"], - self.config_entry.data[CONF_STATION]["type"], - ) - }, - manufacturer=MANUFACTURER, - name=f"Departures at {self.config_entry.data[CONF_STATION]['name']}", - ) - - @property - def name(self): - """Return the name of the sensor.""" - return self.coordinator.data[self.idx]["name"] - - @property - def unique_id(self): - """Return a unique ID to use for this sensor.""" - return self.idx - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return BinarySensorDeviceClass.PROBLEM - @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index 76a7966a6ed..a8efb663c90 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -73,6 +73,19 @@ class HVVDepartureSensor(SensorEntity): station_id = config_entry.data[CONF_STATION]["id"] station_type = config_entry.data[CONF_STATION]["type"] self._attr_unique_id = f"{config_entry.entry_id}-{station_id}-{station_type}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={ + ( + DOMAIN, + config_entry.entry_id, + config_entry.data[CONF_STATION]["id"], + config_entry.data[CONF_STATION]["type"], + ) + }, + manufacturer=MANUFACTURER, + name=config_entry.data[CONF_STATION]["name"], + ) @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self, **kwargs: Any) -> None: @@ -165,20 +178,3 @@ class HVVDepartureSensor(SensorEntity): } ) self._attr_extra_state_attributes[ATTR_NEXT] = departures - - @property - def device_info(self): - """Return the device info for this sensor.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={ - ( - DOMAIN, - self.config_entry.entry_id, - self.config_entry.data[CONF_STATION]["id"], - self.config_entry.data[CONF_STATION]["type"], - ) - }, - manufacturer=MANUFACTURER, - name=self.config_entry.data[CONF_STATION]["name"], - ) diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index ddff1954eb3..9f44d47ecf6 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -51,7 +51,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up Hydrawise from a config entry.""" access_token = config_entry.data[CONF_API_KEY] - hydrawise = legacy.LegacyHydrawise(access_token, load_on_init=False) + hydrawise = legacy.LegacyHydrawiseAsync(access_token) coordinator = HydrawiseDataUpdateCoordinator(hass, hydrawise, SCAN_INTERVAL) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 1953e413672..65355a1829f 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -1,7 +1,7 @@ """Support for Hydrawise sprinkler binary sensors.""" from __future__ import annotations -from pydrawise.legacy import LegacyHydrawise +from pydrawise.schema import Zone import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -69,26 +69,16 @@ async def async_setup_entry( coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][ config_entry.entry_id ] - hydrawise: LegacyHydrawise = coordinator.api - - entities = [ - HydrawiseBinarySensor( - data=hydrawise.current_controller, - coordinator=coordinator, - description=BINARY_SENSOR_STATUS, - device_id_key="controller_id", + entities = [] + for controller in coordinator.data.controllers: + entities.append( + HydrawiseBinarySensor(coordinator, BINARY_SENSOR_STATUS, controller) ) - ] - - # create a sensor for each zone - for zone in hydrawise.relays: - for description in BINARY_SENSOR_TYPES: - entities.append( - HydrawiseBinarySensor( - data=zone, coordinator=coordinator, description=description + for zone in controller.zones: + for description in BINARY_SENSOR_TYPES: + entities.append( + HydrawiseBinarySensor(coordinator, description, controller, zone) ) - ) - async_add_entities(entities) @@ -100,5 +90,5 @@ class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorEntity): if self.entity_description.key == "status": self._attr_is_on = self.coordinator.last_update_success elif self.entity_description.key == "is_watering": - relay_data = self.coordinator.api.relays_by_zone_number[self.data["relay"]] - self._attr_is_on = relay_data["timestr"] == "Now" + zone: Zone = self.zone + self._attr_is_on = zone.scheduled_runs.current_run is not None diff --git a/homeassistant/components/hydrawise/config_flow.py b/homeassistant/components/hydrawise/config_flow.py index c4b37fb4a06..72df86606d7 100644 --- a/homeassistant/components/hydrawise/config_flow.py +++ b/homeassistant/components/hydrawise/config_flow.py @@ -5,8 +5,8 @@ from __future__ import annotations from collections.abc import Callable from typing import Any +from aiohttp import ClientError from pydrawise import legacy -from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol from homeassistant import config_entries @@ -27,20 +27,17 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, api_key: str, *, on_failure: Callable[[str], FlowResult] ) -> FlowResult: """Create the config entry.""" + api = legacy.LegacyHydrawiseAsync(api_key) try: - api = await self.hass.async_add_executor_job( - legacy.LegacyHydrawise, api_key - ) - except ConnectTimeout: + # Skip fetching zones to save on metered API calls. + user = await api.get_user(fetch_zones=False) + except TimeoutError: return on_failure("timeout_connect") - except HTTPError as ex: + except ClientError as ex: LOGGER.error("Unable to connect to Hydrawise cloud service: %s", ex) return on_failure("cannot_connect") - if not api.status: - return on_failure("unknown") - - await self.async_set_unique_id(f"hydrawise-{api.customer_id}") + await self.async_set_unique_id(f"hydrawise-{user.customer_id}") self._abort_if_unique_id_configured() return self.async_create_entry(title="Hydrawise", data={CONF_API_KEY: api_key}) diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index 007b15d2403..412108f859f 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -4,26 +4,25 @@ from __future__ import annotations from datetime import timedelta -from pydrawise.legacy import LegacyHydrawise +from pydrawise import HydrawiseBase +from pydrawise.schema import User from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, LOGGER -class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[None]): +class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[User]): """The Hydrawise Data Update Coordinator.""" def __init__( - self, hass: HomeAssistant, api: LegacyHydrawise, scan_interval: timedelta + self, hass: HomeAssistant, api: HydrawiseBase, scan_interval: timedelta ) -> None: """Initialize HydrawiseDataUpdateCoordinator.""" super().__init__(hass, LOGGER, name=DOMAIN, update_interval=scan_interval) self.api = api - async def _async_update_data(self) -> None: + async def _async_update_data(self) -> User: """Fetch the latest data from Hydrawise.""" - result = await self.hass.async_add_executor_job(self.api.update_controller_info) - if not result: - raise UpdateFailed("Failed to refresh Hydrawise data") + return await self.api.get_user() diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index 38fde322673..c707690ce95 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -1,7 +1,7 @@ """Base classes for Hydrawise entities.""" from __future__ import annotations -from typing import Any +from pydrawise.schema import Controller, Zone from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo @@ -20,23 +20,25 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]): def __init__( self, - *, - data: dict[str, Any], coordinator: HydrawiseDataUpdateCoordinator, description: EntityDescription, - device_id_key: str = "relay_id", + controller: Controller, + zone: Zone | None = None, ) -> None: """Initialize the Hydrawise entity.""" super().__init__(coordinator=coordinator) - self.data = data self.entity_description = description - self._device_id = str(data.get(device_id_key)) + self.controller = controller + self.zone = zone + self._device_id = str(controller.id if zone is None else zone.id) self._attr_unique_id = f"{self._device_id}_{description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._device_id)}, - name=data["name"], + name=controller.name if zone is None else zone.name, manufacturer=MANUFACTURER, ) + if zone is not None: + self._attr_device_info["via_device"] = (DOMAIN, str(controller.id)) self._update_attrs() def _update_attrs(self) -> None: diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 4e73a2ba64c..054d084eb76 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2023.10.0"] + "requirements": ["pydrawise==2023.11.0"] } diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 369e952c1be..79a318f778f 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -1,6 +1,9 @@ """Support for Hydrawise sprinkler sensors.""" from __future__ import annotations +from datetime import datetime + +from pydrawise.schema import Zone import voluptuous as vol from homeassistant.components.sensor import ( @@ -71,27 +74,30 @@ async def async_setup_entry( coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][ config_entry.entry_id ] - entities = [ - HydrawiseSensor(data=zone, coordinator=coordinator, description=description) - for zone in coordinator.api.relays + async_add_entities( + HydrawiseSensor(coordinator, description, controller, zone) + for controller in coordinator.data.controllers + for zone in controller.zones for description in SENSOR_TYPES - ] - async_add_entities(entities) + ) class HydrawiseSensor(HydrawiseEntity, SensorEntity): """A sensor implementation for Hydrawise device.""" + zone: Zone + def _update_attrs(self) -> None: """Update state attributes.""" - relay_data = self.coordinator.api.relays_by_zone_number[self.data["relay"]] if self.entity_description.key == "watering_time": - if relay_data["timestr"] == "Now": - self._attr_native_value = int(relay_data["run"] / 60) + if (current_run := self.zone.scheduled_runs.current_run) is not None: + self._attr_native_value = int( + current_run.remaining_time.total_seconds() / 60 + ) else: self._attr_native_value = 0 - else: # _sensor_type == 'next_cycle' - next_cycle = min(relay_data["time"], TWO_YEAR_SECONDS) - self._attr_native_value = dt_util.utc_from_timestamp( - dt_util.as_timestamp(dt_util.now()) + next_cycle - ) + elif self.entity_description.key == "next_cycle": + if (next_run := self.zone.scheduled_runs.next_run) is not None: + self._attr_native_value = dt_util.as_utc(next_run.start_time) + else: + self._attr_native_value = datetime.max.replace(tzinfo=dt_util.UTC) diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index caaefd7aa26..5dd79d4a13e 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -1,8 +1,10 @@ """Support for Hydrawise cloud switches.""" from __future__ import annotations +from datetime import timedelta from typing import Any +from pydrawise.schema import Zone import voluptuous as vol from homeassistant.components.switch import ( @@ -17,6 +19,7 @@ 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 import dt as dt_util from .const import ( ALLOWED_WATERING_TIME, @@ -76,58 +79,44 @@ async def async_setup_entry( coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][ config_entry.entry_id ] - default_watering_timer = DEFAULT_WATERING_TIME - - entities = [ - HydrawiseSwitch( - data=zone, - coordinator=coordinator, - description=description, - default_watering_timer=default_watering_timer, - ) - for zone in coordinator.api.relays + async_add_entities( + HydrawiseSwitch(coordinator, description, controller, zone) + for controller in coordinator.data.controllers + for zone in controller.zones for description in SWITCH_TYPES - ] - - async_add_entities(entities) + ) class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): """A switch implementation for Hydrawise device.""" - def __init__( - self, - *, - data: dict[str, Any], - coordinator: HydrawiseDataUpdateCoordinator, - description: SwitchEntityDescription, - default_watering_timer: int, - ) -> None: - """Initialize a switch for Hydrawise device.""" - super().__init__(data=data, coordinator=coordinator, description=description) - self._default_watering_timer = default_watering_timer + zone: Zone - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - zone_number = self.data["relay"] if self.entity_description.key == "manual_watering": - self.coordinator.api.run_zone(self._default_watering_timer, zone_number) + await self.coordinator.api.start_zone( + self.zone, custom_run_duration=DEFAULT_WATERING_TIME + ) elif self.entity_description.key == "auto_watering": - self.coordinator.api.suspend_zone(0, zone_number) + await self.coordinator.api.resume_zone(self.zone) + self._attr_is_on = True + self.async_write_ha_state() - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - zone_number = self.data["relay"] if self.entity_description.key == "manual_watering": - self.coordinator.api.run_zone(0, zone_number) + await self.coordinator.api.stop_zone(self.zone) elif self.entity_description.key == "auto_watering": - self.coordinator.api.suspend_zone(365, zone_number) + await self.coordinator.api.suspend_zone( + self.zone, dt_util.now() + timedelta(days=365) + ) + self._attr_is_on = False + self.async_write_ha_state() def _update_attrs(self) -> None: """Update state attributes.""" - zone_number = self.data["relay"] - timestr = self.coordinator.api.relays_by_zone_number[zone_number]["timestr"] if self.entity_description.key == "manual_watering": - self._attr_is_on = timestr == "Now" + self._attr_is_on = self.zone.scheduled_runs.current_run is not None elif self.entity_description.key == "auto_watering": - self._attr_is_on = timestr not in {"", "Now"} + self._attr_is_on = self.zone.status.suspended_until is None diff --git a/homeassistant/components/hyperion/strings.json b/homeassistant/components/hyperion/strings.json index a2f8838e2ea..8d7e3751c4c 100644 --- a/homeassistant/components/hyperion/strings.json +++ b/homeassistant/components/hyperion/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Hyperion server." } }, "auth": { diff --git a/homeassistant/components/ialarm/strings.json b/homeassistant/components/ialarm/strings.json index 1ac7a25e6f8..cb2c75d74a9 100644 --- a/homeassistant/components/ialarm/strings.json +++ b/homeassistant/components/ialarm/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of Antifurto365 iAlarm system." } } }, diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py index 9496752dce7..5e112aa39f7 100644 --- a/homeassistant/components/idasen_desk/__init__.py +++ b/homeassistant/components/idasen_desk/__init__.py @@ -24,7 +24,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN -PLATFORMS: list[Platform] = [Platform.COVER] +PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.COVER, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -44,6 +44,7 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]): super().__init__(hass, logger, name=name) self._address = address self._expected_connected = False + self._connection_lost = False self.desk = Desk(self.async_set_updated_data) @@ -63,6 +64,7 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]): """Disconnect from desk.""" _LOGGER.debug("Disconnecting from %s", self._address) self._expected_connected = False + self._connection_lost = False await self.desk.disconnect() @callback @@ -71,7 +73,11 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]): if self._expected_connected: if not self.desk.is_connected: _LOGGER.debug("Desk disconnected. Reconnecting") + self._connection_lost = True self.hass.async_create_task(self.async_connect()) + elif self._connection_lost: + _LOGGER.info("Reconnected to desk") + self._connection_lost = False elif self.desk.is_connected: _LOGGER.warning("Desk is connected but should not be. Disconnecting") self.hass.async_create_task(self.desk.disconnect()) diff --git a/homeassistant/components/idasen_desk/button.py b/homeassistant/components/idasen_desk/button.py new file mode 100644 index 00000000000..6cae9a42895 --- /dev/null +++ b/homeassistant/components/idasen_desk/button.py @@ -0,0 +1,101 @@ +"""Representation of Idasen Desk buttons.""" +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any, Final + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DeskData, IdasenDeskCoordinator +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class IdasenDeskButtonDescriptionMixin: + """Mixin to describe a IdasenDesk button entity.""" + + press_action: Callable[ + [IdasenDeskCoordinator], Callable[[], Coroutine[Any, Any, Any]] + ] + + +@dataclass +class IdasenDeskButtonDescription( + ButtonEntityDescription, IdasenDeskButtonDescriptionMixin +): + """Class to describe a IdasenDesk button entity.""" + + +BUTTONS: Final = [ + IdasenDeskButtonDescription( + key="connect", + name="Connect", + icon="mdi:bluetooth-connect", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + press_action=lambda coordinator: coordinator.async_connect, + ), + IdasenDeskButtonDescription( + key="disconnect", + name="Disconnect", + icon="mdi:bluetooth-off", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + press_action=lambda coordinator: coordinator.async_disconnect, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set buttons for device.""" + data: DeskData = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + IdasenDeskButton(data.address, data.device_info, data.coordinator, button) + for button in BUTTONS + ) + + +class IdasenDeskButton(ButtonEntity): + """Defines a IdasenDesk button.""" + + entity_description: IdasenDeskButtonDescription + _attr_has_entity_name = True + + def __init__( + self, + address: str, + device_info: DeviceInfo, + coordinator: IdasenDeskCoordinator, + description: IdasenDeskButtonDescription, + ) -> None: + """Initialize the IdasenDesk button entity.""" + self.entity_description = description + + self._attr_unique_id = f"{self.entity_description.key}-{address}" + self._attr_device_info = device_info + self._address = address + self._coordinator = coordinator + + async def async_press(self) -> None: + """Triggers the IdasenDesk button press service.""" + _LOGGER.debug( + "Trigger %s for %s", + self.entity_description.key, + self._address, + ) + await self.entity_description.press_action(self._coordinator)() diff --git a/homeassistant/components/idasen_desk/config_flow.py b/homeassistant/components/idasen_desk/config_flow.py index caa8d866fc3..80282ce0271 100644 --- a/homeassistant/components/idasen_desk/config_flow.py +++ b/homeassistant/components/idasen_desk/config_flow.py @@ -65,14 +65,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): desk = Desk(None, monitor_height=False) try: await desk.connect(discovery_info.device, auto_reconnect=False) - except AuthFailedError as err: - _LOGGER.exception("AuthFailedError", exc_info=err) + except AuthFailedError: errors["base"] = "auth_failed" - except TimeoutError as err: - _LOGGER.exception("TimeoutError", exc_info=err) + except TimeoutError: errors["base"] = "cannot_connect" - except BleakError as err: - _LOGGER.exception("BleakError", exc_info=err) + except BleakError: + _LOGGER.exception("Unexpected Bluetooth error") errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected error") diff --git a/homeassistant/components/idasen_desk/cover.py b/homeassistant/components/idasen_desk/cover.py index 3148616d182..1daebe52420 100644 --- a/homeassistant/components/idasen_desk/cover.py +++ b/homeassistant/components/idasen_desk/cover.py @@ -3,6 +3,8 @@ from __future__ import annotations from typing import Any +from bleak.exc import BleakError + from homeassistant.components.cover import ( ATTR_POSITION, CoverDeviceClass, @@ -12,6 +14,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -71,19 +74,33 @@ class IdasenDeskCover(CoordinatorEntity[IdasenDeskCoordinator], CoverEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self._desk.move_down() + try: + await self._desk.move_down() + except BleakError as err: + raise HomeAssistantError("Failed to move down: Bluetooth error") from err async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._desk.move_up() + try: + await self._desk.move_up() + except BleakError as err: + raise HomeAssistantError("Failed to move up: Bluetooth error") from err async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - await self._desk.stop() + try: + await self._desk.stop() + except BleakError as err: + raise HomeAssistantError("Failed to stop moving: Bluetooth error") from err async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover shutter to a specific position.""" - await self._desk.move_to(int(kwargs[ATTR_POSITION])) + try: + await self._desk.move_to(int(kwargs[ATTR_POSITION])) + except BleakError as err: + raise HomeAssistantError( + "Failed to move to specified position: Bluetooth error" + ) from err @callback def _handle_coordinator_update(self, *args: Any) -> None: diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json index ed941f4f87d..0a96a976bb3 100644 --- a/homeassistant/components/idasen_desk/manifest.json +++ b/homeassistant/components/idasen_desk/manifest.json @@ -11,5 +11,6 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/idasen_desk", "iot_class": "local_push", - "requirements": ["idasen-ha==2.3"] + "quality_scale": "silver", + "requirements": ["idasen-ha==2.4"] } diff --git a/homeassistant/components/idasen_desk/sensor.py b/homeassistant/components/idasen_desk/sensor.py new file mode 100644 index 00000000000..b67dec0f579 --- /dev/null +++ b/homeassistant/components/idasen_desk/sensor.py @@ -0,0 +1,100 @@ +"""Representation of Idasen Desk sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant import config_entries +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfLength +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import DeskData, IdasenDeskCoordinator +from .const import DOMAIN + + +@dataclass +class IdasenDeskSensorDescriptionMixin: + """Required values for IdasenDesk sensors.""" + + value_fn: Callable[[IdasenDeskCoordinator], float | None] + + +@dataclass +class IdasenDeskSensorDescription( + SensorEntityDescription, + IdasenDeskSensorDescriptionMixin, +): + """Class describing IdasenDesk sensor entities.""" + + +SENSORS = ( + IdasenDeskSensorDescription( + key="height", + translation_key="height", + icon="mdi:arrow-up-down", + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + suggested_display_precision=3, + value_fn=lambda coordinator: coordinator.desk.height, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Idasen Desk sensors.""" + data: DeskData = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + IdasenDeskSensor( + data.address, data.device_info, data.coordinator, sensor_description + ) + for sensor_description in SENSORS + ) + + +class IdasenDeskSensor(CoordinatorEntity[IdasenDeskCoordinator], SensorEntity): + """IdasenDesk sensor.""" + + entity_description: IdasenDeskSensorDescription + _attr_has_entity_name = True + + def __init__( + self, + address: str, + device_info: DeviceInfo, + coordinator: IdasenDeskCoordinator, + description: IdasenDeskSensorDescription, + ) -> None: + """Initialize the IdasenDesk sensor entity.""" + super().__init__(coordinator) + self.entity_description = description + + self._attr_unique_id = f"{description.key}-{address}" + self._attr_device_info = device_info + self._address = address + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + @callback + def _handle_coordinator_update(self, *args: Any) -> None: + """Handle data update.""" + self._attr_native_value = self.entity_description.value_fn(self.coordinator) + super()._handle_coordinator_update() diff --git a/homeassistant/components/idasen_desk/strings.json b/homeassistant/components/idasen_desk/strings.json index 6b9bf80edfc..446ef93e542 100644 --- a/homeassistant/components/idasen_desk/strings.json +++ b/homeassistant/components/idasen_desk/strings.json @@ -19,5 +19,12 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_devices_found": "No unconfigured devices found. Make sure that the desk is in Bluetooth pairing mode. Enter pairing mode by pressing the small button with the Bluetooth logo on the controller for about 3 seconds, until it starts blinking." } + }, + "entity": { + "sensor": { + "height": { + "name": "Height" + } + } } } diff --git a/homeassistant/components/imap/__init__.py b/homeassistant/components/imap/__init__.py index 3914e0c52c1..fea2583a27a 100644 --- a/homeassistant/components/imap/__init__.py +++ b/homeassistant/components/imap/__init__.py @@ -66,8 +66,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator = hass.data[ DOMAIN - ].pop( - entry.entry_id - ) + ].pop(entry.entry_id) await coordinator.shutdown() return unload_ok diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index d77f7fb05bb..34286ce49fa 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -167,7 +167,7 @@ class ImapMessage: """ try: return str(part.get_payload(decode=True).decode(self._charset)) - except Exception: # pylint: disable=broad-except + except ValueError: return str(part.get_payload()) part: Message diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 81b75458dc1..01bd76d1241 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Self +from typing import Any, Self import voluptuous as vol @@ -61,20 +61,20 @@ STORAGE_FIELDS = { } -def _cv_input_text(cfg): +def _cv_input_text(config: dict[str, Any]) -> dict[str, Any]: """Configure validation helper for input box (voluptuous).""" - minimum = cfg.get(CONF_MIN) - maximum = cfg.get(CONF_MAX) + minimum: int = config[CONF_MIN] + maximum: int = config[CONF_MAX] if minimum > maximum: raise vol.Invalid( f"Max len ({minimum}) is not greater than min len ({maximum})" ) - state = cfg.get(CONF_INITIAL) + state: str | None = config.get(CONF_INITIAL) if state is not None and (len(state) < minimum or len(state) > maximum): raise vol.Invalid( f"Initial value {state} length not in range {minimum}-{maximum}" ) - return cfg + return config CONFIG_SCHEMA = vol.Schema( @@ -86,7 +86,7 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_MIN, default=CONF_MIN_VALUE): vol.Coerce(int), vol.Optional(CONF_MAX, default=CONF_MAX_VALUE): vol.Coerce(int), - vol.Optional(CONF_INITIAL, ""): cv.string, + vol.Optional(CONF_INITIAL): cv.string, vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_PATTERN): cv.string, @@ -162,16 +162,18 @@ class InputTextStorageCollection(collection.DictStorageCollection): CREATE_UPDATE_SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, _cv_input_text)) - async def _process_create_data(self, data: dict) -> dict: + async def _process_create_data(self, data: dict[str, Any]) -> vol.Schema: """Validate the config is valid.""" return self.CREATE_UPDATE_SCHEMA(data) @callback - def _get_suggested_id(self, info: dict) -> str: + def _get_suggested_id(self, info: dict[str, Any]) -> str: """Suggest an ID based on the config.""" - return info[CONF_NAME] + return info[CONF_NAME] # type: ignore[no-any-return] - async def _update_data(self, item: dict, update_data: dict) -> dict: + async def _update_data( + self, item: dict[str, Any], update_data: dict[str, Any] + ) -> dict[str, Any]: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) return {CONF_ID: item[CONF_ID]} | update_data @@ -185,6 +187,7 @@ class InputText(collection.CollectionEntity, RestoreEntity): ) _attr_should_poll = False + _current_value: str | None editable: bool def __init__(self, config: ConfigType) -> None: @@ -195,55 +198,55 @@ class InputText(collection.CollectionEntity, RestoreEntity): @classmethod def from_storage(cls, config: ConfigType) -> Self: """Return entity instance initialized from storage.""" - input_text = cls(config) + input_text: Self = cls(config) input_text.editable = True return input_text @classmethod def from_yaml(cls, config: ConfigType) -> Self: """Return entity instance initialized from yaml.""" - input_text = cls(config) + input_text: Self = cls(config) input_text.entity_id = f"{DOMAIN}.{config[CONF_ID]}" input_text.editable = False return input_text @property - def name(self): + def name(self) -> str | None: """Return the name of the text input entity.""" return self._config.get(CONF_NAME) @property - def icon(self): + def icon(self) -> str | None: """Return the icon to be used for this entity.""" return self._config.get(CONF_ICON) @property def _maximum(self) -> int: """Return max len of the text.""" - return self._config[CONF_MAX] + return self._config[CONF_MAX] # type: ignore[no-any-return] @property def _minimum(self) -> int: """Return min len of the text.""" - return self._config[CONF_MIN] + return self._config[CONF_MIN] # type: ignore[no-any-return] @property - def state(self): + def state(self) -> str | None: """Return the state of the component.""" return self._current_value @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str | None: """Return the unit the value is expressed in.""" return self._config.get(CONF_UNIT_OF_MEASUREMENT) @property - def unique_id(self) -> str | None: + def unique_id(self) -> str: """Return unique id for the entity.""" - return self._config[CONF_ID] + return self._config[CONF_ID] # type: ignore[no-any-return] @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return { ATTR_EDITABLE: self.editable, @@ -253,20 +256,20 @@ class InputText(collection.CollectionEntity, RestoreEntity): ATTR_MODE: self._config[CONF_MODE], } - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() if self._current_value is not None: return state = await self.async_get_last_state() - value = state and state.state + value: str | None = state and state.state # type: ignore[assignment] # Check against None because value can be 0 if value is not None and self._minimum <= len(value) <= self._maximum: self._current_value = value - async def async_set_value(self, value): + async def async_set_value(self, value: str) -> None: """Select new value.""" if len(value) < self._minimum or len(value) > self._maximum: _LOGGER.warning( diff --git a/homeassistant/components/insteon/config_flow.py b/homeassistant/components/insteon/config_flow.py index f5bafd935a0..36e977f6db0 100644 --- a/homeassistant/components/insteon/config_flow.py +++ b/homeassistant/components/insteon/config_flow.py @@ -38,6 +38,7 @@ from .schemas import ( add_x10_device, build_device_override_schema, build_hub_schema, + build_plm_manual_schema, build_plm_schema, build_remove_override_schema, build_remove_x10_schema, @@ -46,6 +47,7 @@ from .schemas import ( from .utils import async_get_usb_ports STEP_PLM = "plm" +STEP_PLM_MANUALLY = "plm_manually" STEP_HUB_V1 = "hubv1" STEP_HUB_V2 = "hubv2" STEP_CHANGE_HUB_CONFIG = "change_hub_config" @@ -55,6 +57,7 @@ STEP_ADD_OVERRIDE = "add_override" STEP_REMOVE_OVERRIDE = "remove_override" STEP_REMOVE_X10 = "remove_x10" MODEM_TYPE = "modem_type" +PLM_MANUAL = "manual" _LOGGER = logging.getLogger(__name__) @@ -129,16 +132,35 @@ class InsteonFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Set up the PLM modem type.""" errors = {} if user_input is not None: + if user_input[CONF_DEVICE] == PLM_MANUAL: + return await self.async_step_plm_manually() if await _async_connect(**user_input): return self.async_create_entry(title="", data=user_input) errors["base"] = "cannot_connect" schema_defaults = user_input if user_input is not None else {} ports = await async_get_usb_ports(self.hass) + if not ports: + return await self.async_step_plm_manually() + ports[PLM_MANUAL] = "Enter manually" data_schema = build_plm_schema(ports, **schema_defaults) return self.async_show_form( step_id=STEP_PLM, data_schema=data_schema, errors=errors ) + async def async_step_plm_manually(self, user_input=None): + """Set up the PLM modem type manually.""" + errors = {} + schema_defaults = {} + if user_input is not None: + if await _async_connect(**user_input): + return self.async_create_entry(title="", data=user_input) + errors["base"] = "cannot_connect" + schema_defaults = user_input + data_schema = build_plm_manual_schema(**schema_defaults) + return self.async_show_form( + step_id=STEP_PLM_MANUALLY, data_schema=data_schema, errors=errors + ) + async def async_step_hubv1(self, user_input=None): """Set up the Hub v1 modem type.""" return await self._async_setup_hub(hub_version=1, user_input=user_input) diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index 5fa45a16fb6..1d4eee4a058 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["pyinsteon", "pypubsub"], "requirements": [ - "pyinsteon==1.5.1", + "pyinsteon==1.5.2", "insteon-frontend-home-assistant==0.4.0" ], "usb": [ diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py index e6b22a8cbb9..497af743195 100644 --- a/homeassistant/components/insteon/schemas.py +++ b/homeassistant/components/insteon/schemas.py @@ -195,6 +195,11 @@ def build_plm_schema(ports: dict[str, str], device=vol.UNDEFINED): return vol.Schema({vol.Required(CONF_DEVICE, default=device): vol.In(ports)}) +def build_plm_manual_schema(device=vol.UNDEFINED): + """Build the manual PLM schema for config flow.""" + return vol.Schema({vol.Required(CONF_DEVICE, default=device): str}) + + def build_hub_schema( hub_version, host=vol.UNDEFINED, diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 5d3a2259bed..306f169106b 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -10,6 +10,11 @@ from homeassistant.components.cover import ( SERVICE_OPEN_COVER, ) from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, + SERVICE_LOCK, + SERVICE_UNLOCK, +) from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TOGGLE, @@ -74,13 +79,37 @@ class OnOffIntentHandler(intent.ServiceIntentHandler): if state.domain == COVER_DOMAIN: # on = open # off = close + if self.service == SERVICE_TURN_ON: + service_name = SERVICE_OPEN_COVER + else: + service_name = SERVICE_CLOSE_COVER + await self._run_then_background( hass.async_create_task( hass.services.async_call( COVER_DOMAIN, - SERVICE_OPEN_COVER - if self.service == SERVICE_TURN_ON - else SERVICE_CLOSE_COVER, + service_name, + {ATTR_ENTITY_ID: state.entity_id}, + context=intent_obj.context, + blocking=True, + ) + ) + ) + return + + if state.domain == LOCK_DOMAIN: + # on = lock + # off = unlock + if self.service == SERVICE_TURN_ON: + service_name = SERVICE_LOCK + else: + service_name = SERVICE_UNLOCK + + await self._run_then_background( + hass.async_create_task( + hass.services.async_call( + LOCK_DOMAIN, + service_name, {ATTR_ENTITY_ID: state.entity_id}, context=intent_obj.context, blocking=True, diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index d5bec0573b8..d184dad47c9 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -6,6 +6,7 @@ from typing import Any, TypedDict import voluptuous as vol +from homeassistant.components.script import CONF_MODE from homeassistant.const import CONF_TYPE, SERVICE_RELOAD from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import ( @@ -43,6 +44,9 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional( CONF_ASYNC_ACTION, default=DEFAULT_CONF_ASYNC_ACTION ): cv.boolean, + vol.Optional(CONF_MODE, default=script.DEFAULT_SCRIPT_MODE): vol.In( + script.SCRIPT_MODE_CHOICES + ), vol.Optional(CONF_CARD): { vol.Optional(CONF_TYPE, default="simple"): cv.string, vol.Required(CONF_TITLE): cv.template, @@ -87,8 +91,13 @@ def async_load_intents(hass: HomeAssistant, intents: dict[str, ConfigType]) -> N for intent_type, conf in intents.items(): if CONF_ACTION in conf: + script_mode: str = conf.get(CONF_MODE, script.DEFAULT_SCRIPT_MODE) conf[CONF_ACTION] = script.Script( - hass, conf[CONF_ACTION], f"Intent Script {intent_type}", DOMAIN + hass, + conf[CONF_ACTION], + f"Intent Script {intent_type}", + DOMAIN, + script_mode=script_mode, ) intent.async_register(hass, ScriptIntentHandler(intent_type, conf)) diff --git a/homeassistant/components/iotawatt/strings.json b/homeassistant/components/iotawatt/strings.json index f21dfe0cd09..266b32c5c31 100644 --- a/homeassistant/components/iotawatt/strings.json +++ b/homeassistant/components/iotawatt/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your IoTaWatt device." } }, "auth": { diff --git a/homeassistant/components/ipp/sensor.py b/homeassistant/components/ipp/sensor.py index 3bc7035e26b..a2cb5cd34dc 100644 --- a/homeassistant/components/ipp/sensor.py +++ b/homeassistant/components/ipp/sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LOCATION, PERCENTAGE, EntityCategory @@ -119,6 +120,7 @@ async def async_setup_entry( name=marker.name, icon="mdi:water", native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, attributes_fn=_get_marker_attributes_fn( index, lambda marker: { diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index 86ee94f7269..2925ca527bc 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -32,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b coordinator = IslamicPrayerDataUpdateCoordinator(hass) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, coordinator) + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator config_entry.async_on_unload( config_entry.add_update_listener(async_options_updated) ) @@ -46,15 +46,19 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if unload_ok := await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ): - coordinator: IslamicPrayerDataUpdateCoordinator = hass.data.pop(DOMAIN) + coordinator: IslamicPrayerDataUpdateCoordinator = hass.data[DOMAIN].pop( + config_entry.entry_id + ) if coordinator.event_unsub: coordinator.event_unsub() + if not hass.data[DOMAIN]: + del hass.data[DOMAIN] return unload_ok async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: """Triggered by config entry options updates.""" - coordinator: IslamicPrayerDataUpdateCoordinator = hass.data[DOMAIN] + coordinator: IslamicPrayerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] if coordinator.event_unsub: coordinator.event_unsub() await coordinator.async_request_refresh() diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index 161ce7b2644..aedaf43411a 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -77,6 +77,7 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim midnightMode=self.midnight_mode, school=self.school, date=str(dt_util.now().date()), + iso8601=True, ) return cast(dict[str, Any], calc.fetch_prayer_times()) @@ -145,9 +146,12 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim async_call_later(self.hass, 60, self.async_request_update) raise UpdateFailed from err + # introduced in prayer-times-calculator 0.0.8 + prayer_times.pop("date", None) + prayer_times_info: dict[str, datetime] = {} for prayer, time in prayer_times.items(): - if prayer_time := dt_util.parse_datetime(f"{dt_util.now().date()} {time}"): + if prayer_time := dt_util.parse_datetime(time): prayer_times_info[prayer] = dt_util.as_utc(prayer_time) self.async_schedule_future_update(prayer_times_info["Midnight"]) diff --git a/homeassistant/components/islamic_prayer_times/manifest.json b/homeassistant/components/islamic_prayer_times/manifest.json index c87cb2d28ac..7d2dd178788 100644 --- a/homeassistant/components/islamic_prayer_times/manifest.json +++ b/homeassistant/components/islamic_prayer_times/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/islamic_prayer_times", "iot_class": "cloud_polling", "loggers": ["prayer_times_calculator"], - "requirements": ["prayer-times-calculator==0.0.6"] + "requirements": ["prayer-times-calculator==0.0.10"] } diff --git a/homeassistant/components/islamic_prayer_times/sensor.py b/homeassistant/components/islamic_prayer_times/sensor.py index 45270863f01..70b2c9d9cc6 100644 --- a/homeassistant/components/islamic_prayer_times/sensor.py +++ b/homeassistant/components/islamic_prayer_times/sensor.py @@ -54,7 +54,9 @@ async def async_setup_entry( ) -> None: """Set up the Islamic prayer times sensor platform.""" - coordinator: IslamicPrayerDataUpdateCoordinator = hass.data[DOMAIN] + coordinator: IslamicPrayerDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] async_add_entities( IslamicPrayerTimeSensor(coordinator, description) diff --git a/homeassistant/components/keenetic_ndms2/strings.json b/homeassistant/components/keenetic_ndms2/strings.json index 13e3fabfbff..765a3fc4d47 100644 --- a/homeassistant/components/keenetic_ndms2/strings.json +++ b/homeassistant/components/keenetic_ndms2/strings.json @@ -9,6 +9,9 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Keenetic router." } } }, diff --git a/homeassistant/components/kmtronic/strings.json b/homeassistant/components/kmtronic/strings.json index 2a3a3a40687..6cecea12f22 100644 --- a/homeassistant/components/kmtronic/strings.json +++ b/homeassistant/components/kmtronic/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your KMtronic device." } } }, diff --git a/homeassistant/components/kodi/strings.json b/homeassistant/components/kodi/strings.json index 51431b317d6..7c7d53b33ac 100644 --- a/homeassistant/components/kodi/strings.json +++ b/homeassistant/components/kodi/strings.json @@ -8,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "ssl": "[%key:common::config_flow::data::ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of the system hosting your Kodi server." } }, "discovery_confirm": { diff --git a/homeassistant/components/kostal_plenticore/diagnostics.py b/homeassistant/components/kostal_plenticore/diagnostics.py index 2e061d35528..eef9f05537f 100644 --- a/homeassistant/components/kostal_plenticore/diagnostics.py +++ b/homeassistant/components/kostal_plenticore/diagnostics.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.components.diagnostics import REDACTED, async_redact_data from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD +from homeassistant.const import ATTR_IDENTIFIERS, CONF_PASSWORD from homeassistant.core import HomeAssistant from .const import DOMAIN @@ -36,7 +36,7 @@ async def async_get_config_entry_diagnostics( } device_info = {**plenticore.device_info} - device_info["identifiers"] = REDACTED # contains serial number + device_info[ATTR_IDENTIFIERS] = REDACTED # contains serial number data["device"] = device_info return data diff --git a/homeassistant/components/kostal_plenticore/helper.py b/homeassistant/components/kostal_plenticore/helper.py index 1c495ac9db9..adb1bfb6f09 100644 --- a/homeassistant/components/kostal_plenticore/helper.py +++ b/homeassistant/components/kostal_plenticore/helper.py @@ -3,13 +3,18 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import Callable +from collections.abc import Callable, Mapping from datetime import datetime, timedelta import logging from typing import Any, TypeVar, cast from aiohttp.client_exceptions import ClientError -from pykoplenti import ApiClient, ApiException, AuthenticationException +from pykoplenti import ( + ApiClient, + ApiException, + AuthenticationException, + ExtendedApiClient, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP from homeassistant.core import CALLBACK_TYPE, HomeAssistant @@ -51,7 +56,9 @@ class Plenticore: async def async_setup(self) -> bool: """Set up Plenticore API client.""" - self._client = ApiClient(async_get_clientsession(self.hass), host=self.host) + self._client = ExtendedApiClient( + async_get_clientsession(self.hass), host=self.host + ) try: await self._client.login(self.config_entry.data[CONF_PASSWORD]) except AuthenticationException as err: @@ -124,7 +131,7 @@ class DataUpdateCoordinatorMixin: async def async_read_data( self, module_id: str, data_id: str - ) -> dict[str, dict[str, str]] | None: + ) -> Mapping[str, Mapping[str, str]] | None: """Read data from Plenticore.""" if (client := self._plenticore.client) is None: return None @@ -190,7 +197,7 @@ class PlenticoreUpdateCoordinator(DataUpdateCoordinator[_DataT]): class ProcessDataUpdateCoordinator( - PlenticoreUpdateCoordinator[dict[str, dict[str, str]]] + PlenticoreUpdateCoordinator[Mapping[str, Mapping[str, str]]] ): """Implementation of PlenticoreUpdateCoordinator for process data.""" @@ -206,18 +213,19 @@ class ProcessDataUpdateCoordinator( return { module_id: { process_data.id: process_data.value - for process_data in fetched_data[module_id] + for process_data in fetched_data[module_id].values() } for module_id in fetched_data } class SettingDataUpdateCoordinator( - PlenticoreUpdateCoordinator[dict[str, dict[str, str]]], DataUpdateCoordinatorMixin + PlenticoreUpdateCoordinator[Mapping[str, Mapping[str, str]]], + DataUpdateCoordinatorMixin, ): """Implementation of PlenticoreUpdateCoordinator for settings data.""" - async def _async_update_data(self) -> dict[str, dict[str, str]]: + async def _async_update_data(self) -> Mapping[str, Mapping[str, str]]: client = self._plenticore.client if not self._fetch or client is None: diff --git a/homeassistant/components/kostal_plenticore/manifest.json b/homeassistant/components/kostal_plenticore/manifest.json index 95f4a194977..d65368e7ee4 100644 --- a/homeassistant/components/kostal_plenticore/manifest.json +++ b/homeassistant/components/kostal_plenticore/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/kostal_plenticore", "iot_class": "local_polling", "loggers": ["kostal"], - "requirements": ["pykoplenti==1.0.0"] + "requirements": ["pykoplenti==1.2.2"] } diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index f7bad638df4..ce18867511d 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -649,6 +649,39 @@ SENSOR_PROCESS_DATA = [ state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), + PlenticoreSensorEntityDescription( + module_id="scb:statistic:EnergyFlow", + key="Statistic:EnergyDischarge:Day", + name="Battery Discharge Day", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + formatter="format_energy", + ), + PlenticoreSensorEntityDescription( + module_id="scb:statistic:EnergyFlow", + key="Statistic:EnergyDischarge:Month", + name="Battery Discharge Month", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + formatter="format_energy", + ), + PlenticoreSensorEntityDescription( + module_id="scb:statistic:EnergyFlow", + key="Statistic:EnergyDischarge:Year", + name="Battery Discharge Year", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + formatter="format_energy", + ), + PlenticoreSensorEntityDescription( + module_id="scb:statistic:EnergyFlow", + key="Statistic:EnergyDischarge:Total", + name="Battery Discharge Total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + formatter="format_energy", + ), PlenticoreSensorEntityDescription( module_id="scb:statistic:EnergyFlow", key="Statistic:EnergyDischargeGrid:Day", @@ -682,6 +715,52 @@ SENSOR_PROCESS_DATA = [ state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), + PlenticoreSensorEntityDescription( + module_id="_virt_", + key="pv_P", + name="Sum power of all PV DC inputs", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=True, + state_class=SensorStateClass.MEASUREMENT, + formatter="format_round", + ), + PlenticoreSensorEntityDescription( + module_id="_virt_", + key="Statistic:EnergyGrid:Total", + name="Energy to Grid Total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + formatter="format_energy", + ), + PlenticoreSensorEntityDescription( + module_id="_virt_", + key="Statistic:EnergyGrid:Year", + name="Energy to Grid Year", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + formatter="format_energy", + ), + PlenticoreSensorEntityDescription( + module_id="_virt_", + key="Statistic:EnergyGrid:Month", + name="Energy to Grid Month", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + formatter="format_energy", + ), + PlenticoreSensorEntityDescription( + module_id="_virt_", + key="Statistic:EnergyGrid:Day", + name="Energy to Grid Day", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + formatter="format_energy", + ), ] diff --git a/homeassistant/components/kraken/sensor.py b/homeassistant/components/kraken/sensor.py index a6c00e62b62..21eb3f2e5a1 100644 --- a/homeassistant/components/kraken/sensor.py +++ b/homeassistant/components/kraken/sensor.py @@ -259,7 +259,8 @@ class KrakenSensor( return try: self._attr_native_value = self.entity_description.value_fn( - self.coordinator, self.tracked_asset_pair_wsname # type: ignore[arg-type] + self.coordinator, # type: ignore[arg-type] + self.tracked_asset_pair_wsname, ) self._received_data_at_least_once = True except KeyError: diff --git a/homeassistant/components/lacrosse/sensor.py b/homeassistant/components/lacrosse/sensor.py index 7355a60f5f0..40d38da55eb 100644 --- a/homeassistant/components/lacrosse/sensor.py +++ b/homeassistant/components/lacrosse/sensor.py @@ -209,7 +209,7 @@ class LaCrosseHumidity(LaCrosseSensor): _attr_native_unit_of_measurement = PERCENTAGE _attr_state_class = SensorStateClass.MEASUREMENT - _attr_icon = "mdi:water-percent" + _attr_device_class = SensorDeviceClass.HUMIDITY @property def native_value(self) -> int | None: diff --git a/homeassistant/components/lametric/button.py b/homeassistant/components/lametric/button.py index 18a0c2f8f72..1de8c1d1717 100644 --- a/homeassistant/components/lametric/button.py +++ b/homeassistant/components/lametric/button.py @@ -19,20 +19,13 @@ from .entity import LaMetricEntity from .helpers import lametric_exception_handler -@dataclass -class LaMetricButtonEntityDescriptionMixin: - """Mixin values for LaMetric entities.""" +@dataclass(kw_only=True) +class LaMetricButtonEntityDescription(ButtonEntityDescription): + """Class describing LaMetric button entities.""" press_fn: Callable[[LaMetricDevice], Awaitable[Any]] -@dataclass -class LaMetricButtonEntityDescription( - ButtonEntityDescription, LaMetricButtonEntityDescriptionMixin -): - """Class describing LaMetric button entities.""" - - BUTTONS = [ LaMetricButtonEntityDescription( key="app_next", diff --git a/homeassistant/components/lametric/number.py b/homeassistant/components/lametric/number.py index da458cab61e..d8c70494264 100644 --- a/homeassistant/components/lametric/number.py +++ b/homeassistant/components/lametric/number.py @@ -19,21 +19,14 @@ from .entity import LaMetricEntity from .helpers import lametric_exception_handler -@dataclass -class LaMetricEntityDescriptionMixin: - """Mixin values for LaMetric entities.""" +@dataclass(kw_only=True) +class LaMetricNumberEntityDescription(NumberEntityDescription): + """Class describing LaMetric number entities.""" value_fn: Callable[[Device], int | None] set_value_fn: Callable[[LaMetricDevice, float], Awaitable[Any]] -@dataclass -class LaMetricNumberEntityDescription( - NumberEntityDescription, LaMetricEntityDescriptionMixin -): - """Class describing LaMetric number entities.""" - - NUMBERS = [ LaMetricNumberEntityDescription( key="brightness", diff --git a/homeassistant/components/lametric/select.py b/homeassistant/components/lametric/select.py index b7c0e55745e..f15147235ac 100644 --- a/homeassistant/components/lametric/select.py +++ b/homeassistant/components/lametric/select.py @@ -19,21 +19,14 @@ from .entity import LaMetricEntity from .helpers import lametric_exception_handler -@dataclass -class LaMetricEntityDescriptionMixin: - """Mixin values for LaMetric entities.""" +@dataclass(kw_only=True) +class LaMetricSelectEntityDescription(SelectEntityDescription): + """Class describing LaMetric select entities.""" current_fn: Callable[[Device], str] select_fn: Callable[[LaMetricDevice, str], Awaitable[Any]] -@dataclass -class LaMetricSelectEntityDescription( - SelectEntityDescription, LaMetricEntityDescriptionMixin -): - """Class describing LaMetric select entities.""" - - SELECTS = [ LaMetricSelectEntityDescription( key="brightness_mode", diff --git a/homeassistant/components/lametric/sensor.py b/homeassistant/components/lametric/sensor.py index 6cddf81b2bf..88d461e9d4f 100644 --- a/homeassistant/components/lametric/sensor.py +++ b/homeassistant/components/lametric/sensor.py @@ -21,20 +21,13 @@ from .coordinator import LaMetricDataUpdateCoordinator from .entity import LaMetricEntity -@dataclass -class LaMetricEntityDescriptionMixin: - """Mixin values for LaMetric entities.""" +@dataclass(kw_only=True) +class LaMetricSensorEntityDescription(SensorEntityDescription): + """Class describing LaMetric sensor entities.""" value_fn: Callable[[Device], int | None] -@dataclass -class LaMetricSensorEntityDescription( - SensorEntityDescription, LaMetricEntityDescriptionMixin -): - """Class describing LaMetric sensor entities.""" - - SENSORS = [ LaMetricSensorEntityDescription( key="rssi", diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index e7bfc059674..87bda01e305 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -41,7 +41,11 @@ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "reauth_device_not_found": "The device you are trying to re-authenticate is not found in this LaMetric account", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" } }, "entity": { diff --git a/homeassistant/components/lametric/switch.py b/homeassistant/components/lametric/switch.py index c33ec16d617..ace492fe0cb 100644 --- a/homeassistant/components/lametric/switch.py +++ b/homeassistant/components/lametric/switch.py @@ -19,21 +19,13 @@ from .entity import LaMetricEntity from .helpers import lametric_exception_handler -@dataclass -class LaMetricEntityDescriptionMixin: - """Mixin values for LaMetric entities.""" - - is_on_fn: Callable[[Device], bool] - set_fn: Callable[[LaMetricDevice, bool], Awaitable[Any]] - - -@dataclass -class LaMetricSwitchEntityDescription( - SwitchEntityDescription, LaMetricEntityDescriptionMixin -): +@dataclass(kw_only=True) +class LaMetricSwitchEntityDescription(SwitchEntityDescription): """Class describing LaMetric switch entities.""" available_fn: Callable[[Device], bool] = lambda device: True + is_on_fn: Callable[[Device], bool] + set_fn: Callable[[LaMetricDevice, bool], Awaitable[Any]] SWITCHES = [ diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index 8ef81e899b7..d7485e88fb0 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -316,7 +316,9 @@ class HeatMeterSensor( """Set up the sensor with the initial values.""" super().__init__(coordinator) self.key = description.key - self._attr_unique_id = f"{coordinator.config_entry.data['device_number']}_{description.key}" # type: ignore[union-attr] + self._attr_unique_id = ( + f"{coordinator.config_entry.data['device_number']}_{description.key}" # type: ignore[union-attr] + ) self._attr_name = f"Heat Meter {description.name}" self.entity_description = description self._attr_device_info = device diff --git a/homeassistant/components/lastfm/config_flow.py b/homeassistant/components/lastfm/config_flow.py index 54406a6e03b..4ff809b56d0 100644 --- a/homeassistant/components/lastfm/config_flow.py +++ b/homeassistant/components/lastfm/config_flow.py @@ -19,7 +19,6 @@ from homeassistant.helpers.selector import ( SelectSelector, SelectSelectorConfig, ) -from homeassistant.helpers.typing import ConfigType from .const import CONF_MAIN_USER, CONF_USERS, DOMAIN @@ -154,24 +153,6 @@ class LastFmConfigFlowHandler(ConfigFlow, domain=DOMAIN): ), ) - async def async_step_import(self, import_config: ConfigType) -> FlowResult: - """Import config from yaml.""" - for entry in self._async_current_entries(): - if entry.options[CONF_API_KEY] == import_config[CONF_API_KEY]: - return self.async_abort(reason="already_configured") - users, _ = validate_lastfm_users( - import_config[CONF_API_KEY], import_config[CONF_USERS] - ) - return self.async_create_entry( - title="LastFM", - data={}, - options={ - CONF_API_KEY: import_config[CONF_API_KEY], - CONF_MAIN_USER: None, - CONF_USERS: users, - }, - ) - class LastFmOptionsFlowHandler(OptionsFlowWithConfigEntry): """LastFm Options flow handler.""" diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index 40d6521bdc9..2b022a00107 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -4,17 +4,11 @@ from __future__ import annotations import hashlib from typing import Any -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_API_KEY -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import SensorEntity +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 homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -28,43 +22,6 @@ from .const import ( ) from .coordinator import LastFMDataUpdateCoordinator, LastFMUserData -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_USERS, default=[]): vol.All(cv.ensure_list, [cv.string]), - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Last.fm sensor platform from yaml.""" - - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2023.12.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "LastFM", - }, - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 7996376b6ac..9fd407b1636 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.14.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.15.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 21543ad6788..6ecd4ed636e 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -32,5 +32,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.14.0", "led-ble==1.0.1"] + "requirements": ["bluetooth-data-tools==1.15.0", "led-ble==1.0.1"] } diff --git a/homeassistant/components/lg_soundbar/strings.json b/homeassistant/components/lg_soundbar/strings.json index 8c6a9909ff5..ee16a39350c 100644 --- a/homeassistant/components/lg_soundbar/strings.json +++ b/homeassistant/components/lg_soundbar/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your LG Soundbar." } } }, diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 7cabfd4712f..39412780331 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -29,9 +29,11 @@ "LIFX GU10", "LIFX Lightstrip", "LIFX Mini", + "LIFX Neon", "LIFX Nightvision", "LIFX Pls", "LIFX Plus", + "LIFX String", "LIFX Tile", "LIFX White", "LIFX Z" @@ -40,8 +42,8 @@ "iot_class": "local_polling", "loggers": ["aiolifx", "aiolifx_effects", "bitstring"], "requirements": [ - "aiolifx==0.8.10", + "aiolifx==1.0.0", "aiolifx-effects==0.3.2", - "aiolifx-themes==0.4.5" + "aiolifx-themes==0.4.10" ] } diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index c327081fabd..21f3b3fe52b 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -6,6 +6,9 @@ "description": "If you leave the host empty, discovery will be used to find devices.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your LIFX device." } }, "pick_device": { diff --git a/homeassistant/components/light/reproduce_state.py b/homeassistant/components/light/reproduce_state.py index f055f02ebda..54fcd01843c 100644 --- a/homeassistant/components/light/reproduce_state.py +++ b/homeassistant/components/light/reproduce_state.py @@ -17,15 +17,10 @@ from homeassistant.core import Context, HomeAssistant, State from . import ( ATTR_BRIGHTNESS, - ATTR_BRIGHTNESS_PCT, ATTR_COLOR_MODE, - ATTR_COLOR_NAME, ATTR_COLOR_TEMP, ATTR_EFFECT, - ATTR_FLASH, ATTR_HS_COLOR, - ATTR_KELVIN, - ATTR_PROFILE, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, @@ -40,13 +35,7 @@ _LOGGER = logging.getLogger(__name__) VALID_STATES = {STATE_ON, STATE_OFF} -ATTR_GROUP = [ - ATTR_BRIGHTNESS, - ATTR_BRIGHTNESS_PCT, - ATTR_EFFECT, - ATTR_FLASH, - ATTR_TRANSITION, -] +ATTR_GROUP = [ATTR_BRIGHTNESS, ATTR_EFFECT] COLOR_GROUP = [ ATTR_HS_COLOR, @@ -55,10 +44,6 @@ COLOR_GROUP = [ ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, ATTR_XY_COLOR, - # The following color attributes are deprecated - ATTR_PROFILE, - ATTR_COLOR_NAME, - ATTR_KELVIN, ] @@ -79,21 +64,6 @@ COLOR_MODE_TO_ATTRIBUTE = { ColorMode.XY: ColorModeAttr(ATTR_XY_COLOR, ATTR_XY_COLOR), } -DEPRECATED_GROUP = [ - ATTR_BRIGHTNESS_PCT, - ATTR_COLOR_NAME, - ATTR_FLASH, - ATTR_KELVIN, - ATTR_PROFILE, - ATTR_TRANSITION, -] - -DEPRECATION_WARNING = ( - "The use of other attributes than device state attributes is deprecated and will be" - " removed in a future release. Invalid attributes are %s. Read the logs for further" - " details: https://www.home-assistant.io/integrations/scene/" -) - def _color_mode_same(cur_state: State, state: State) -> bool: """Test if color_mode is same.""" @@ -124,11 +94,6 @@ async def _async_reproduce_state( ) return - # Warn if deprecated attributes are used - deprecated_attrs = [attr for attr in state.attributes if attr in DEPRECATED_GROUP] - if deprecated_attrs: - _LOGGER.warning(DEPRECATION_WARNING, deprecated_attrs) - # Return if we are already at the right state. if ( cur_state.state == state.state diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 433da53a570..fb7a1539944 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -252,8 +252,9 @@ turn_on: - light.ColorMode.RGBWW selector: color_temp: - min_mireds: 153 - max_mireds: 500 + unit: "mired" + min: 153 + max: 500 kelvin: filter: attribute: @@ -266,11 +267,10 @@ turn_on: - light.ColorMode.RGBWW advanced: true selector: - number: + color_temp: + unit: "kelvin" min: 2000 max: 6500 - step: 100 - unit_of_measurement: K brightness: filter: attribute: @@ -637,11 +637,10 @@ toggle: - light.ColorMode.RGBWW advanced: true selector: - number: + color_temp: + unit: "kelvin" min: 2000 max: 6500 - step: 100 - unit_of_measurement: K brightness: filter: attribute: diff --git a/homeassistant/components/linear_garage_door/__init__.py b/homeassistant/components/linear_garage_door/__init__.py new file mode 100644 index 00000000000..d168da511e0 --- /dev/null +++ b/homeassistant/components/linear_garage_door/__init__.py @@ -0,0 +1,32 @@ +"""The Linear Garage Door integration.""" +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 LinearUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.COVER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Linear Garage Door from a config entry.""" + + coordinator = LinearUpdateCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/linear_garage_door/config_flow.py b/homeassistant/components/linear_garage_door/config_flow.py new file mode 100644 index 00000000000..6bca49adb4c --- /dev/null +++ b/homeassistant/components/linear_garage_door/config_flow.py @@ -0,0 +1,166 @@ +"""Config flow for Linear Garage Door integration.""" +from __future__ import annotations + +from collections.abc import Collection, Mapping, Sequence +import logging +from typing import Any +import uuid + +from linear_garage_door import Linear +from linear_garage_door.errors import InvalidLoginError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, +} + + +async def validate_input( + hass: HomeAssistant, + data: dict[str, str], +) -> dict[str, Sequence[Collection[str]]]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + + hub = Linear() + + device_id = str(uuid.uuid4()) + try: + await hub.login( + data["email"], + data["password"], + device_id=device_id, + client_session=async_get_clientsession(hass), + ) + + sites = await hub.get_sites() + except InvalidLoginError as err: + raise InvalidAuth from err + finally: + await hub.close() + + info = { + "email": data["email"], + "password": data["password"], + "sites": sites, + "device_id": device_id, + } + + return info + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Linear Garage Door.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self.data: dict[str, Sequence[Collection[str]]] = {} + self._reauth_entry: config_entries.ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + data_schema = STEP_USER_DATA_SCHEMA + + data_schema = vol.Schema(data_schema) + + if user_input is None: + return self.async_show_form(step_id="user", data_schema=data_schema) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self.data = info + + # Check if we are reauthenticating + if self._reauth_entry is not None: + self.hass.config_entries.async_update_entry( + self._reauth_entry, + data=self._reauth_entry.data + | {"email": self.data["email"], "password": self.data["password"]}, + ) + await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return await self.async_step_site() + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + async def async_step_site( + self, + user_input: dict[str, Any] | None = None, + ) -> FlowResult: + """Handle the site step.""" + + if isinstance(self.data["sites"], list): + sites: list[dict[str, str]] = self.data["sites"] + + if not user_input: + return self.async_show_form( + step_id="site", + data_schema=vol.Schema( + { + vol.Required("site"): vol.In( + {site["id"]: site["name"] for site in sites} + ) + } + ), + ) + + site_id = user_input["site"] + + site_name = next(site["name"] for site in sites if site["id"] == site_id) + + await self.async_set_unique_id(site_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=site_name, + data={ + "site_id": site_id, + "email": self.data["email"], + "password": self.data["password"], + "device_id": self.data["device_id"], + }, + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Reauth in case of a password change or other error.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_user() + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class InvalidDeviceID(HomeAssistantError): + """Error to indicate there is invalid device ID.""" diff --git a/homeassistant/components/linear_garage_door/const.py b/homeassistant/components/linear_garage_door/const.py new file mode 100644 index 00000000000..7b3625c7c67 --- /dev/null +++ b/homeassistant/components/linear_garage_door/const.py @@ -0,0 +1,3 @@ +"""Constants for the Linear Garage Door integration.""" + +DOMAIN = "linear_garage_door" diff --git a/homeassistant/components/linear_garage_door/coordinator.py b/homeassistant/components/linear_garage_door/coordinator.py new file mode 100644 index 00000000000..5a17d5a39e4 --- /dev/null +++ b/homeassistant/components/linear_garage_door/coordinator.py @@ -0,0 +1,81 @@ +"""DataUpdateCoordinator for Linear.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from linear_garage_door import Linear +from linear_garage_door.errors import InvalidLoginError, ResponseError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class LinearUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """DataUpdateCoordinator for Linear.""" + + _email: str + _password: str + _device_id: str + _site_id: str + _devices: list[dict[str, list[str] | str]] | None + _linear: Linear + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> None: + """Initialize DataUpdateCoordinator for Linear.""" + self._email = entry.data["email"] + self._password = entry.data["password"] + self._device_id = entry.data["device_id"] + self._site_id = entry.data["site_id"] + self._devices = None + + super().__init__( + hass, + _LOGGER, + name="Linear Garage Door", + update_interval=timedelta(seconds=60), + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Get the data for Linear.""" + + linear = Linear() + + try: + await linear.login( + email=self._email, + password=self._password, + device_id=self._device_id, + ) + except InvalidLoginError as err: + if ( + str(err) + == "Login error: Login provided is invalid, please check the email and password" + ): + raise ConfigEntryAuthFailed from err + raise ConfigEntryNotReady from err + except ResponseError as err: + raise ConfigEntryNotReady from err + + if not self._devices: + self._devices = await linear.get_devices(self._site_id) + + data = {} + + for device in self._devices: + device_id = str(device["id"]) + state = await linear.get_device_state(device_id) + data[device_id] = {"name": device["name"], "subdevices": state} + + await linear.close() + + return data diff --git a/homeassistant/components/linear_garage_door/cover.py b/homeassistant/components/linear_garage_door/cover.py new file mode 100644 index 00000000000..3474e9d3acb --- /dev/null +++ b/homeassistant/components/linear_garage_door/cover.py @@ -0,0 +1,149 @@ +"""Cover entity for Linear Garage Doors.""" + +from datetime import timedelta +from typing import Any + +from linear_garage_door import Linear + +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import LinearUpdateCoordinator + +SUPPORTED_SUBDEVICES = ["GDO"] +PARALLEL_UPDATES = 1 +SCAN_INTERVAL = timedelta(seconds=10) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Linear Garage Door cover.""" + coordinator: LinearUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + data = coordinator.data + + device_list: list[LinearCoverEntity] = [] + + for device_id in data: + device_list.extend( + LinearCoverEntity( + device_id=device_id, + device_name=data[device_id]["name"], + subdevice=subdev, + config_entry=config_entry, + coordinator=coordinator, + ) + for subdev in data[device_id]["subdevices"] + if subdev in SUPPORTED_SUBDEVICES + ) + async_add_entities(device_list) + + +class LinearCoverEntity(CoordinatorEntity[LinearUpdateCoordinator], CoverEntity): + """Representation of a Linear cover.""" + + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + def __init__( + self, + device_id: str, + device_name: str, + subdevice: str, + config_entry: ConfigEntry, + coordinator: LinearUpdateCoordinator, + ) -> None: + """Init with device ID and name.""" + super().__init__(coordinator) + + self._attr_has_entity_name = True + self._attr_name = None + self._device_id = device_id + self._device_name = device_name + self._subdevice = subdevice + self._attr_device_class = CoverDeviceClass.GARAGE + self._attr_unique_id = f"{device_id}-{subdevice}" + self._config_entry = config_entry + + def _get_data(self, data_property: str) -> str: + """Get a property of the subdevice.""" + return str( + self.coordinator.data[self._device_id]["subdevices"][self._subdevice].get( + data_property + ) + ) + + @property + def device_info(self) -> DeviceInfo: + """Return device info of a garage door.""" + return DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + name=self._device_name, + manufacturer="Linear", + model="Garage Door Opener", + ) + + @property + def is_closed(self) -> bool: + """Return if cover is closed.""" + return bool(self._get_data("Open_B") == "false") + + @property + def is_opened(self) -> bool: + """Return if cover is open.""" + return bool(self._get_data("Open_B") == "true") + + @property + def is_opening(self) -> bool: + """Return if cover is opening.""" + return bool(self._get_data("Opening_P") == "0") + + @property + def is_closing(self) -> bool: + """Return if cover is closing.""" + return bool(self._get_data("Opening_P") == "100") + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the garage door.""" + if self.is_closed: + return + + linear = Linear() + + await linear.login( + email=self._config_entry.data["email"], + password=self._config_entry.data["password"], + device_id=self._config_entry.data["device_id"], + client_session=async_get_clientsession(self.hass), + ) + + await linear.operate_device(self._device_id, self._subdevice, "Close") + await linear.close() + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the garage door.""" + if self.is_opened: + return + + linear = Linear() + + await linear.login( + email=self._config_entry.data["email"], + password=self._config_entry.data["password"], + device_id=self._config_entry.data["device_id"], + client_session=async_get_clientsession(self.hass), + ) + + await linear.operate_device(self._device_id, self._subdevice, "Open") + await linear.close() diff --git a/homeassistant/components/linear_garage_door/diagnostics.py b/homeassistant/components/linear_garage_door/diagnostics.py new file mode 100644 index 00000000000..fffcdd7de87 --- /dev/null +++ b/homeassistant/components/linear_garage_door/diagnostics.py @@ -0,0 +1,26 @@ +"""Diagnostics support for Linear Garage Door.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import LinearUpdateCoordinator + +TO_REDACT = {CONF_PASSWORD, CONF_EMAIL} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: LinearUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + return { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "coordinator_data": coordinator.data, + } diff --git a/homeassistant/components/linear_garage_door/manifest.json b/homeassistant/components/linear_garage_door/manifest.json new file mode 100644 index 00000000000..c7918e21e20 --- /dev/null +++ b/homeassistant/components/linear_garage_door/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "linear_garage_door", + "name": "Linear Garage Door", + "codeowners": ["@IceBotYT"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/linear_garage_door", + "iot_class": "cloud_polling", + "requirements": ["linear-garage-door==0.2.7"] +} diff --git a/homeassistant/components/linear_garage_door/strings.json b/homeassistant/components/linear_garage_door/strings.json new file mode 100644 index 00000000000..93dd17c5bce --- /dev/null +++ b/homeassistant/components/linear_garage_door/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "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%]" + } + } +} diff --git a/homeassistant/components/local_calendar/__init__.py b/homeassistant/components/local_calendar/__init__.py index 7c1d2f09b04..3b302742ab6 100644 --- a/homeassistant/components/local_calendar/__init__.py +++ b/homeassistant/components/local_calendar/__init__.py @@ -7,9 +7,10 @@ from pathlib import Path from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.util import slugify -from .const import CONF_CALENDAR_NAME, DOMAIN +from .const import CONF_CALENDAR_NAME, CONF_STORAGE_KEY, DOMAIN from .store import LocalCalendarStore _LOGGER = logging.getLogger(__name__) @@ -24,9 +25,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Local Calendar from a config entry.""" hass.data.setdefault(DOMAIN, {}) - key = slugify(entry.data[CONF_CALENDAR_NAME]) - path = Path(hass.config.path(STORAGE_PATH.format(key=key))) - hass.data[DOMAIN][entry.entry_id] = LocalCalendarStore(hass, path) + if CONF_STORAGE_KEY not in entry.data: + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_STORAGE_KEY: slugify(entry.data[CONF_CALENDAR_NAME]), + }, + ) + + path = Path(hass.config.path(STORAGE_PATH.format(key=entry.data[CONF_STORAGE_KEY]))) + store = LocalCalendarStore(hass, path) + try: + await store.async_load() + except OSError as err: + raise ConfigEntryNotReady("Failed to load file {path}: {err}") from err + + hass.data[DOMAIN][entry.entry_id] = store await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/local_calendar/config_flow.py b/homeassistant/components/local_calendar/config_flow.py index 2bde06820b6..a5a75fee58b 100644 --- a/homeassistant/components/local_calendar/config_flow.py +++ b/homeassistant/components/local_calendar/config_flow.py @@ -7,8 +7,9 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.data_entry_flow import FlowResult +from homeassistant.util import slugify -from .const import CONF_CALENDAR_NAME, DOMAIN +from .const import CONF_CALENDAR_NAME, CONF_STORAGE_KEY, DOMAIN STEP_USER_DATA_SCHEMA = vol.Schema( { @@ -31,6 +32,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) + key = slugify(user_input[CONF_CALENDAR_NAME]) + self._async_abort_entries_match({CONF_STORAGE_KEY: key}) + user_input[CONF_STORAGE_KEY] = key return self.async_create_entry( title=user_input[CONF_CALENDAR_NAME], data=user_input ) diff --git a/homeassistant/components/local_calendar/const.py b/homeassistant/components/local_calendar/const.py index 49cd5dc22a4..1cfa774ab0a 100644 --- a/homeassistant/components/local_calendar/const.py +++ b/homeassistant/components/local_calendar/const.py @@ -3,3 +3,4 @@ DOMAIN = "local_calendar" CONF_CALENDAR_NAME = "calendar_name" +CONF_STORAGE_KEY = "storage_key" diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index cd30c2eeebe..c5cf25a8c2e 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -90,6 +90,9 @@ class LocalTodoListEntity(TodoListEntity): | TodoListEntityFeature.DELETE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.MOVE_TODO_ITEM + | TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) _attr_should_poll = False @@ -115,6 +118,8 @@ class LocalTodoListEntity(TodoListEntity): status=ICS_TODO_STATUS_MAP.get( item.status or TodoStatus.NEEDS_ACTION, TodoItemStatus.NEEDS_ACTION ), + due=item.due, + description=item.description, ) for item in self._calendar.todos ] diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index 8cbce69dc7c..ed7e2070055 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -87,40 +87,34 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def _async_lock(entity: LockEntity, service_call: ServiceCall) -> None: - """Lock the lock.""" - code: str = service_call.data.get( - ATTR_CODE, entity._lock_option_default_code # pylint: disable=protected-access - ) +@callback +def _add_default_code(entity: LockEntity, service_call: ServiceCall) -> dict[Any, Any]: + data = remove_entity_service_fields(service_call) + code: str = data.pop(ATTR_CODE, "") + if not code: + code = entity._lock_option_default_code # pylint: disable=protected-access if entity.code_format_cmp and not entity.code_format_cmp.match(code): raise ValueError( f"Code '{code}' for locking {entity.entity_id} doesn't match pattern {entity.code_format}" ) - await entity.async_lock(**remove_entity_service_fields(service_call)) + if code: + data[ATTR_CODE] = code + return data + + +async def _async_lock(entity: LockEntity, service_call: ServiceCall) -> None: + """Lock the lock.""" + await entity.async_lock(**_add_default_code(entity, service_call)) async def _async_unlock(entity: LockEntity, service_call: ServiceCall) -> None: """Unlock the lock.""" - code: str = service_call.data.get( - ATTR_CODE, entity._lock_option_default_code # pylint: disable=protected-access - ) - if entity.code_format_cmp and not entity.code_format_cmp.match(code): - raise ValueError( - f"Code '{code}' for unlocking {entity.entity_id} doesn't match pattern {entity.code_format}" - ) - await entity.async_unlock(**remove_entity_service_fields(service_call)) + await entity.async_unlock(**_add_default_code(entity, service_call)) async def _async_open(entity: LockEntity, service_call: ServiceCall) -> None: """Open the door latch.""" - code: str = service_call.data.get( - ATTR_CODE, entity._lock_option_default_code # pylint: disable=protected-access - ) - if entity.code_format_cmp and not entity.code_format_cmp.match(code): - raise ValueError( - f"Code '{code}' for opening {entity.entity_id} doesn't match pattern {entity.code_format}" - ) - await entity.async_open(**remove_entity_service_fields(service_call)) + await entity.async_open(**_add_default_code(entity, service_call)) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index 82c05e612e3..6939904f520 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -42,9 +42,6 @@ class LazyEventPartialState: "event_type", "entity_id", "state", - "context_id_bin", - "context_user_id_bin", - "context_parent_id_bin", "data", ] @@ -60,9 +57,6 @@ class LazyEventPartialState: self.event_type: str | None = self.row.event_type self.entity_id: str | None = self.row.entity_id self.state = self.row.state - self.context_id_bin: bytes | None = self.row.context_id_bin - self.context_user_id_bin: bytes | None = self.row.context_user_id_bin - self.context_parent_id_bin: bytes | None = self.row.context_parent_id_bin # We need to explicitly check for the row is EventAsRow as the unhappy path # to fetch row.data for Row is very expensive if type(row) is EventAsRow: # noqa: E721 @@ -83,17 +77,17 @@ class LazyEventPartialState: @property def context_id(self) -> str | None: """Return the context id.""" - return bytes_to_ulid_or_none(self.context_id_bin) + return bytes_to_ulid_or_none(self.row.context_id_bin) @property def context_user_id(self) -> str | None: """Return the context user id.""" - return bytes_to_uuid_hex_or_none(self.context_user_id_bin) + return bytes_to_uuid_hex_or_none(self.row.context_user_id_bin) @property def context_parent_id(self) -> str | None: """Return the context parent id.""" - return bytes_to_ulid_or_none(self.context_parent_id_bin) + return bytes_to_ulid_or_none(self.row.context_parent_id_bin) @dataclass(slots=True, frozen=True) diff --git a/homeassistant/components/logbook/queries/all.py b/homeassistant/components/logbook/queries/all.py index c6196687ac2..21f88135a1d 100644 --- a/homeassistant/components/logbook/queries/all.py +++ b/homeassistant/components/logbook/queries/all.py @@ -28,18 +28,13 @@ def all_stmt( ) if context_id_bin is not None: stmt += lambda s: s.where(Events.context_id_bin == context_id_bin).union_all( - _states_query_for_context_id( - start_day, - end_day, - # https://github.com/python/mypy/issues/2608 - context_id_bin, # type:ignore[arg-type] - ), + _states_query_for_context_id(start_day, end_day, context_id_bin), ) elif filters and filters.has_config: stmt = stmt.add_criteria( - lambda q: q.filter(filters.events_entity_filter()).union_all( # type: ignore[union-attr] + lambda q: q.filter(filters.events_entity_filter()).union_all( _states_query_for_all(start_day, end_day).where( - filters.states_metadata_entity_filter() # type: ignore[union-attr] + filters.states_metadata_entity_filter() ) ), track_on=[filters], diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index 7656de8d385..37156e9ca08 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -118,7 +118,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: push_coordinator = LookinPushCoordinator(entry.title) if lookin_device.model >= 2: - meteo_coordinator = LookinDataUpdateCoordinator[MeteoSensor]( + coordinator_class = LookinDataUpdateCoordinator[MeteoSensor] + meteo_coordinator = coordinator_class( hass, push_coordinator, name=entry.title, diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 2c425bec785..daa44bf60be 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -4,7 +4,10 @@ import logging import voluptuous as vol from homeassistant.components import frontend, websocket_api -from homeassistant.config import async_hass_config_yaml, async_process_component_config +from homeassistant.config import ( + async_hass_config_yaml, + async_process_component_and_handle_errors, +) from homeassistant.const import CONF_FILENAME, CONF_MODE, CONF_RESOURCES from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError @@ -85,7 +88,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: integration = await async_get_integration(hass, DOMAIN) - config = await async_process_component_config(hass, conf, integration) + config = await async_process_component_and_handle_errors( + hass, conf, integration + ) if config is None: raise HomeAssistantError("Config validation failed") diff --git a/homeassistant/components/lupusec/binary_sensor.py b/homeassistant/components/lupusec/binary_sensor.py index c98e634dcb3..ee369baf8dd 100644 --- a/homeassistant/components/lupusec/binary_sensor.py +++ b/homeassistant/components/lupusec/binary_sensor.py @@ -27,7 +27,7 @@ def setup_platform( data = hass.data[LUPUSEC_DOMAIN] - device_types = CONST.TYPE_OPENING + device_types = CONST.TYPE_OPENING + CONST.TYPE_SENSOR devices = [] for device in data.lupusec.get_devices(generic_type=device_types): diff --git a/homeassistant/components/lutron_caseta/strings.json b/homeassistant/components/lutron_caseta/strings.json index b5ec175d1c9..0fb906f097f 100644 --- a/homeassistant/components/lutron_caseta/strings.json +++ b/homeassistant/components/lutron_caseta/strings.json @@ -11,6 +11,9 @@ "description": "Enter the IP address of the device.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Lutron Caseta Smart Bridge." } }, "link": { diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index d0bad55ff14..f01e4c4fe55 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -324,6 +324,15 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): "Could not find target_temp_low and/or target_temp_high in" " arguments" ) + + # If the device supports "Auto" mode, don't pass the mode when setting the + # temperature + mode = ( + None + if device.changeableValues.mode == LYRIC_HVAC_MODE_HEAT_COOL + else HVAC_MODES[device.changeableValues.heatCoolMode] + ) + _LOGGER.debug("Set temperature: %s - %s", target_temp_low, target_temp_high) try: await self._update_thermostat( @@ -331,7 +340,7 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): device, coolSetpoint=target_temp_high, heatSetpoint=target_temp_low, - mode=HVAC_MODES[device.changeableValues.heatCoolMode], + mode=mode, ) except LYRIC_EXCEPTIONS as exception: _LOGGER.error(exception) diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index 219530a9747..68bb6292f9e 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -12,7 +12,11 @@ "abort": { "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index f9ef3593fe6..ddda50aa8b2 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -348,7 +348,10 @@ class MatrixBot: self._access_tokens[self._mx_id] = token await self.hass.async_add_executor_job( - save_json, self._session_filepath, self._access_tokens, True # private=True + save_json, + self._session_filepath, + self._access_tokens, + True, # private=True ) async def _login(self) -> None: diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index a2aa2c5ceff..b58c4562994 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from contextlib import suppress +from functools import cache from matter_server.client import MatterClient from matter_server.client.exceptions import CannotConnect, InvalidServerVersion @@ -28,12 +29,34 @@ from .addon import get_addon_manager from .api import async_register_api from .const import CONF_INTEGRATION_CREATED_ADDON, CONF_USE_ADDON, DOMAIN, LOGGER from .discovery import SUPPORTED_PLATFORMS -from .helpers import MatterEntryData, get_matter, get_node_from_device_entry +from .helpers import ( + MatterEntryData, + get_matter, + get_node_from_device_entry, + node_from_ha_device_id, +) +from .models import MatterDeviceInfo CONNECT_TIMEOUT = 10 LISTEN_READY_TIMEOUT = 30 +@callback +@cache +def get_matter_device_info( + hass: HomeAssistant, device_id: str +) -> MatterDeviceInfo | None: + """Return Matter device info or None if device does not exist.""" + if not (node := node_from_ha_device_id(hass, device_id)): + return None + + return MatterDeviceInfo( + unique_id=node.device_info.uniqueID, + vendor_id=hex(node.device_info.vendorID), + product_id=hex(node.device_info.productID), + ) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Matter from a config entry.""" if use_addon := entry.data.get(CONF_USE_ADDON): @@ -190,7 +213,7 @@ async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove a config entry from a device.""" - node = await get_node_from_device_entry(hass, device_entry) + node = get_node_from_device_entry(hass, device_entry) if node is None: return True @@ -218,21 +241,11 @@ async def async_remove_config_entry_device( def _async_init_services(hass: HomeAssistant) -> None: """Init services.""" - async def _node_id_from_ha_device_id(ha_device_id: str) -> int | None: - """Get node id from ha device id.""" - dev_reg = dr.async_get(hass) - device = dev_reg.async_get(ha_device_id) - if device is None: - return None - if node := await get_node_from_device_entry(hass, device): - return node.node_id - return None - async def open_commissioning_window(call: ServiceCall) -> None: """Open commissioning window on specific node.""" - node_id = await _node_id_from_ha_device_id(call.data["device_id"]) + node = node_from_ha_device_id(hass, call.data["device_id"]) - if node_id is None: + if node is None: raise HomeAssistantError("This is not a Matter device") matter_client = get_matter(hass).matter_client @@ -240,7 +253,7 @@ def _async_init_services(hass: HomeAssistant) -> None: # We are sending device ID . try: - await matter_client.open_commissioning_window(node_id) + await matter_client.open_commissioning_window(node.node_id) except NodeCommissionFailed as err: raise HomeAssistantError(str(err)) from err diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index 2831ebe9a38..5690996841d 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -97,22 +97,23 @@ class MatterAdapter: self.config_entry.async_on_unload( self.matter_client.subscribe_events( - endpoint_added_callback, EventType.ENDPOINT_ADDED + callback=endpoint_added_callback, event_filter=EventType.ENDPOINT_ADDED ) ) self.config_entry.async_on_unload( self.matter_client.subscribe_events( - endpoint_removed_callback, EventType.ENDPOINT_REMOVED + callback=endpoint_removed_callback, + event_filter=EventType.ENDPOINT_REMOVED, ) ) self.config_entry.async_on_unload( self.matter_client.subscribe_events( - node_removed_callback, EventType.NODE_REMOVED + callback=node_removed_callback, event_filter=EventType.NODE_REMOVED ) ) self.config_entry.async_on_unload( self.matter_client.subscribe_events( - node_added_callback, EventType.NODE_ADDED + callback=node_added_callback, event_filter=EventType.NODE_ADDED ) ) diff --git a/homeassistant/components/matter/diagnostics.py b/homeassistant/components/matter/diagnostics.py index bcb41cc0462..8846a75b42a 100644 --- a/homeassistant/components/matter/diagnostics.py +++ b/homeassistant/components/matter/diagnostics.py @@ -58,7 +58,7 @@ async def async_get_device_diagnostics( """Return diagnostics for a device.""" matter = get_matter(hass) server_diagnostics = await matter.matter_client.get_diagnostics() - node = await get_node_from_device_entry(hass, device) + node = get_node_from_device_entry(hass, device) return { "server_info": dataclass_to_dict(server_diagnostics.info), diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index c971bf8465e..e1d004a15c8 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -115,8 +115,9 @@ def async_discover_entities( attributes_to_watch=attributes_to_watch, entity_description=schema.entity_description, entity_class=schema.entity_class, + should_poll=schema.should_poll, ) - # prevent re-discovery of the same attributes + # prevent re-discovery of the primary attribute if not allowed if not schema.allow_multi: - discovered_attributes.update(attributes_to_watch) + discovered_attributes.update(schema.required_attributes) diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 7e7b7a688df..de6e6ff83c2 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -5,6 +5,7 @@ from abc import abstractmethod from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass +from datetime import datetime import logging from typing import TYPE_CHECKING, Any, cast @@ -12,9 +13,10 @@ from chip.clusters.Objects import ClusterAttributeDescriptor, NullValue from matter_server.common.helpers.util import create_attribute_path from matter_server.common.models import EventType, ServerInfoMessage -from homeassistant.core import callback +from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.event import async_call_later from .const import DOMAIN, ID_TYPE_DEVICE_ID from .helpers import get_device_id @@ -27,6 +29,13 @@ if TYPE_CHECKING: LOGGER = logging.getLogger(__name__) +# For some manually polled values (e.g. custom clusters) we perform +# an additional poll as soon as a secondary value changes. +# For example update the energy consumption meter when a relay is toggled +# of an energy metering powerplug. The below constant defined the delay after +# which we poll the primary value (debounced). +EXTRA_POLL_DELAY = 3.0 + @dataclass class MatterEntityDescription(EntityDescription): @@ -39,7 +48,6 @@ class MatterEntityDescription(EntityDescription): class MatterEntity(Entity): """Entity class for Matter devices.""" - _attr_should_poll = False _attr_has_entity_name = True def __init__( @@ -71,6 +79,8 @@ class MatterEntity(Entity): identifiers={(DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}")} ) self._attr_available = self._endpoint.node.available + self._attr_should_poll = entity_info.should_poll + self._extra_poll_timer_unsub: CALLBACK_TYPE | None = None async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" @@ -110,15 +120,35 @@ class MatterEntity(Entity): async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" + if self._extra_poll_timer_unsub: + self._extra_poll_timer_unsub() for unsub in self._unsubscribes: with suppress(ValueError): # suppress ValueError to prevent race conditions unsub() + async def async_update(self) -> None: + """Call when the entity needs to be updated.""" + # manually poll/refresh the primary value + await self.matter_client.refresh_attribute( + self._endpoint.node.node_id, + self.get_matter_attribute_path(self._entity_info.primary_attribute), + ) + self._update_from_device() + @callback def _on_matter_event(self, event: EventType, data: Any = None) -> None: - """Call on update.""" + """Call on update from the device.""" self._attr_available = self._endpoint.node.available + if self._attr_should_poll: + # secondary attribute updated of a polled primary value + # enforce poll of the primary value a few seconds later + if self._extra_poll_timer_unsub: + self._extra_poll_timer_unsub() + self._extra_poll_timer_unsub = async_call_later( + self.hass, EXTRA_POLL_DELAY, self._do_extra_poll + ) + return self._update_from_device() self.async_write_ha_state() @@ -145,3 +175,9 @@ class MatterEntity(Entity): return create_attribute_path( self._endpoint.endpoint_id, attribute.cluster_id, attribute.attribute_id ) + + @callback + def _do_extra_poll(self, called_at: datetime) -> None: + """Perform (extra) poll of primary value.""" + # scheduling the regulat update is enough to perform a poll/refresh + self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py index 3361c3fa146..e84fcec32d8 100644 --- a/homeassistant/components/matter/event.py +++ b/homeassistant/components/matter/event.py @@ -104,9 +104,11 @@ class MatterEventEntity(MatterEntity, EventEntity): """Call when Node attribute(s) changed.""" @callback - def _on_matter_node_event( - self, event: EventType, data: MatterNodeEvent - ) -> None: # noqa: F821 + def _on_matter_node_event( # noqa: F821 + self, + event: EventType, + data: MatterNodeEvent, + ) -> None: """Call on NodeEvent.""" if data.endpoint_id != self._endpoint.endpoint_id: return diff --git a/homeassistant/components/matter/helpers.py b/homeassistant/components/matter/helpers.py index 0274c80edf8..446d5dc3591 100644 --- a/homeassistant/components/matter/helpers.py +++ b/homeassistant/components/matter/helpers.py @@ -66,7 +66,18 @@ def get_device_id( return f"{operational_instance_id}-{postfix}" -async def get_node_from_device_entry( +@callback +def node_from_ha_device_id(hass: HomeAssistant, ha_device_id: str) -> MatterNode | None: + """Get node id from ha device id.""" + dev_reg = dr.async_get(hass) + device = dev_reg.async_get(ha_device_id) + if device is None: + raise ValueError("Invalid device ID") + return get_node_from_device_entry(hass, device) + + +@callback +def get_node_from_device_entry( hass: HomeAssistant, device: dr.DeviceEntry ) -> MatterNode | None: """Return MatterNode from device entry.""" @@ -83,7 +94,7 @@ async def get_node_from_device_entry( ) if device_id_full is None: - raise ValueError(f"Device {device.id} is not a Matter device") + return None device_id = device_id_full.lstrip(device_id_type_prefix) matter_client = matter.matter_client diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 174ebb1cab9..f350cda9227 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==4.0.2"] + "requirements": ["python-matter-server==5.0.0"] } diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index 3ac7f66b83f..5f47f73b139 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import TypedDict from chip.clusters import Objects as clusters from chip.clusters.Objects import ClusterAttributeDescriptor @@ -16,6 +17,20 @@ SensorValueTypes = type[ ] +class MatterDeviceInfo(TypedDict): + """Dictionary with Matter Device info. + + Used to send to other Matter controllers, + such as Google Home to prevent duplicated devices. + + Reference: https://developers.home.google.com/matter/device-deduplication + """ + + unique_id: str + vendor_id: str # vendorId hex string + product_id: str # productId hex string + + @dataclass class MatterEntityInfo: """Info discovered from (primary) Matter Attribute to create entity.""" @@ -35,6 +50,9 @@ class MatterEntityInfo: # entity class to use to instantiate the entity entity_class: type + # [optional] bool to specify if this primary value should be polled + should_poll: bool + @property def primary_attribute(self) -> type[ClusterAttributeDescriptor]: """Return Primary Attribute belonging to the entity.""" @@ -91,3 +109,6 @@ class MatterDiscoverySchema: # [optional] bool to specify if this primary value may be discovered # by multiple platforms allow_multi: bool = False + + # [optional] bool to specify if this primary value should be polled + should_poll: bool = False diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 5021ed7fa0d..6262eb253aa 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from chip.clusters import Objects as clusters from chip.clusters.Types import Nullable, NullValue +from matter_server.client.models.clusters import EveEnergyCluster from homeassistant.components.sensor import ( SensorDeviceClass, @@ -18,6 +19,10 @@ from homeassistant.const import ( PERCENTAGE, EntityCategory, Platform, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, UnitOfPressure, UnitOfTemperature, UnitOfVolumeFlowRate, @@ -48,7 +53,6 @@ class MatterSensorEntityDescription(SensorEntityDescription, MatterEntityDescrip class MatterSensor(MatterEntity, SensorEntity): """Representation of a Matter sensor.""" - _attr_state_class = SensorStateClass.MEASUREMENT entity_description: MatterSensorEntityDescription @callback @@ -72,6 +76,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, measurement_to_ha=lambda x: x / 100, + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.TemperatureMeasurement.Attributes.MeasuredValue,), @@ -83,6 +88,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfPressure.KPA, device_class=SensorDeviceClass.PRESSURE, measurement_to_ha=lambda x: x / 10, + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.PressureMeasurement.Attributes.MeasuredValue,), @@ -94,6 +100,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, translation_key="flow", measurement_to_ha=lambda x: x / 10, + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.FlowMeasurement.Attributes.MeasuredValue,), @@ -105,6 +112,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, measurement_to_ha=lambda x: x / 100, + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=( @@ -118,6 +126,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.IlluminanceMeasurement.Attributes.MeasuredValue,), @@ -131,8 +140,71 @@ DISCOVERY_SCHEMAS = [ entity_category=EntityCategory.DIAGNOSTIC, # value has double precision measurement_to_ha=lambda x: int(x / 2), + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveEnergySensorWatt", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(EveEnergyCluster.Attributes.Watt,), + # Add OnOff Attribute as optional attribute to poll + # the primary value when the relay is toggled + optional_attributes=(clusters.OnOff.Attributes.OnOff,), + should_poll=True, + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveEnergySensorVoltage", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=0, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(EveEnergyCluster.Attributes.Voltage,), + should_poll=True, + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveEnergySensorWattAccumulated", + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=3, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + entity_class=MatterSensor, + required_attributes=(EveEnergyCluster.Attributes.WattAccumulated,), + should_poll=True, + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveEnergySensorWattCurrent", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(EveEnergyCluster.Attributes.Current,), + # Add OnOff Attribute as optional attribute to poll + # the primary value when the relay is toggled + optional_attributes=(clusters.OnOff.Attributes.OnOff,), + should_poll=True, + ), ] diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index d16439800a9..111509c1f31 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -7,5 +7,5 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp==2023.10.13"] + "requirements": ["yt-dlp==2023.11.16"] } diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index f3ff925a1a4..50365f90f1f 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1137,8 +1137,7 @@ class MediaPlayerImageView(HomeAssistantView): extra_urls = [ # Need to modify the default regex for media_content_id as it may # include arbitrary characters including '/','{', or '}' - url - + "/browse_media/{media_content_type}/{media_content_id:.+}", + url + "/browse_media/{media_content_type}/{media_content_id:.+}", ] def __init__(self, component: EntityComponent[MediaPlayerEntity]) -> None: diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 589223dc0f3..9d2a4f08257 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -20,6 +20,7 @@ from homeassistant.components.climate import ( DEFAULT_MIN_TEMP, ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.config_entries import ConfigEntry @@ -60,6 +61,18 @@ ATW_ZONE_HVAC_MODE_LOOKUP = { } ATW_ZONE_HVAC_MODE_REVERSE_LOOKUP = {v: k for k, v in ATW_ZONE_HVAC_MODE_LOOKUP.items()} +ATW_ZONE_HVAC_ACTION_LOOKUP = { + atw.STATUS_IDLE: HVACAction.IDLE, + atw.STATUS_HEAT_ZONES: HVACAction.HEATING, + atw.STATUS_COOL: HVACAction.COOLING, + atw.STATUS_STANDBY: HVACAction.IDLE, + # Heating water tank, so the zone is idle + atw.STATUS_HEAT_WATER: HVACAction.IDLE, + atw.STATUS_LEGIONELLA: HVACAction.IDLE, + # Heat pump cannot heat in this mode, but will be ready soon + atw.STATUS_DEFROST: HVACAction.PREHEATING, +} + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -351,6 +364,13 @@ class AtwDeviceZoneClimate(MelCloudClimate): """Return the list of available hvac operation modes.""" return [self.hvac_mode] + @property + def hvac_action(self) -> HVACAction | None: + """Return the current running hvac operation.""" + if not self._device.power: + return HVACAction.OFF + return ATW_ZONE_HVAC_ACTION_LOOKUP.get(self._device.status) + @property def current_temperature(self) -> float | None: """Return the current temperature.""" diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index 53764252043..a10a07b5374 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -1,29 +1,12 @@ """The met component.""" from __future__ import annotations -from collections.abc import Callable -from datetime import timedelta import logging -from random import randrange -from types import MappingProxyType -from typing import Any, Self - -import metno from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_ELEVATION, - CONF_LATITUDE, - CONF_LONGITUDE, - EVENT_CORE_CONFIG_UPDATE, - Platform, -) -from homeassistant.core import Event, HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt as dt_util from .const import ( CONF_TRACK_HOME, @@ -31,9 +14,7 @@ from .const import ( DEFAULT_HOME_LONGITUDE, DOMAIN, ) - -# Dedicated Home Assistant endpoint - do not change! -URL = "https://aa015h6buqvih86i1.api.met.no/weatherapi/locationforecast/2.0/complete" +from .coordinator import MetDataUpdateCoordinator PLATFORMS = [Platform.WEATHER] @@ -98,98 +79,3 @@ async def cleanup_old_device(hass: HomeAssistant) -> None: if device: _LOGGER.debug("Removing improper device %s", device.name) device_reg.async_remove_device(device.id) - - -class CannotConnect(HomeAssistantError): - """Unable to connect to the web site.""" - - -class MetDataUpdateCoordinator(DataUpdateCoordinator["MetWeatherData"]): - """Class to manage fetching Met data.""" - - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: - """Initialize global Met data updater.""" - self._unsub_track_home: Callable[[], None] | None = None - self.weather = MetWeatherData(hass, config_entry.data) - self.weather.set_coordinates() - - update_interval = timedelta(minutes=randrange(55, 65)) - - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - - async def _async_update_data(self) -> MetWeatherData: - """Fetch data from Met.""" - try: - return await self.weather.fetch_data() - except Exception as err: - raise UpdateFailed(f"Update failed: {err}") from err - - def track_home(self) -> None: - """Start tracking changes to HA home setting.""" - if self._unsub_track_home: - return - - async def _async_update_weather_data(_event: Event | None = None) -> None: - """Update weather data.""" - if self.weather.set_coordinates(): - await self.async_refresh() - - self._unsub_track_home = self.hass.bus.async_listen( - EVENT_CORE_CONFIG_UPDATE, _async_update_weather_data - ) - - def untrack_home(self) -> None: - """Stop tracking changes to HA home setting.""" - if self._unsub_track_home: - self._unsub_track_home() - self._unsub_track_home = None - - -class MetWeatherData: - """Keep data for Met.no weather entities.""" - - def __init__(self, hass: HomeAssistant, config: MappingProxyType[str, Any]) -> None: - """Initialise the weather entity data.""" - self.hass = hass - self._config = config - self._weather_data: metno.MetWeatherData - self.current_weather_data: dict = {} - self.daily_forecast: list[dict] = [] - self.hourly_forecast: list[dict] = [] - self._coordinates: dict[str, str] | None = None - - def set_coordinates(self) -> bool: - """Weather data initialization - set the coordinates.""" - if self._config.get(CONF_TRACK_HOME, False): - latitude = self.hass.config.latitude - longitude = self.hass.config.longitude - elevation = self.hass.config.elevation - else: - latitude = self._config[CONF_LATITUDE] - longitude = self._config[CONF_LONGITUDE] - elevation = self._config[CONF_ELEVATION] - - coordinates = { - "lat": str(latitude), - "lon": str(longitude), - "msl": str(elevation), - } - if coordinates == self._coordinates: - return False - self._coordinates = coordinates - - self._weather_data = metno.MetWeatherData( - coordinates, async_get_clientsession(self.hass), api_url=URL - ) - return True - - async def fetch_data(self) -> Self: - """Fetch data from API - (current weather and forecast).""" - resp = await self._weather_data.fetching_data() - if not resp: - raise CannotConnect() - self.current_weather_data = self._weather_data.get_current_weather() - time_zone = dt_util.DEFAULT_TIME_ZONE - self.daily_forecast = self._weather_data.get_forecast(time_zone, False, 0) - self.hourly_forecast = self._weather_data.get_forecast(time_zone, True) - return self diff --git a/homeassistant/components/met/coordinator.py b/homeassistant/components/met/coordinator.py new file mode 100644 index 00000000000..6354e286cee --- /dev/null +++ b/homeassistant/components/met/coordinator.py @@ -0,0 +1,126 @@ +"""DataUpdateCoordinator for Met.no integration.""" +from __future__ import annotations + +from collections.abc import Callable +from datetime import timedelta +import logging +from random import randrange +from types import MappingProxyType +from typing import Any, Self + +import metno + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_ELEVATION, + CONF_LATITUDE, + CONF_LONGITUDE, + EVENT_CORE_CONFIG_UPDATE, +) +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import CONF_TRACK_HOME, DOMAIN + +# Dedicated Home Assistant endpoint - do not change! +URL = "https://aa015h6buqvih86i1.api.met.no/weatherapi/locationforecast/2.0/complete" + +_LOGGER = logging.getLogger(__name__) + + +class CannotConnect(HomeAssistantError): + """Unable to connect to the web site.""" + + +class MetWeatherData: + """Keep data for Met.no weather entities.""" + + def __init__(self, hass: HomeAssistant, config: MappingProxyType[str, Any]) -> None: + """Initialise the weather entity data.""" + self.hass = hass + self._config = config + self._weather_data: metno.MetWeatherData + self.current_weather_data: dict = {} + self.daily_forecast: list[dict] = [] + self.hourly_forecast: list[dict] = [] + self._coordinates: dict[str, str] | None = None + + def set_coordinates(self) -> bool: + """Weather data initialization - set the coordinates.""" + if self._config.get(CONF_TRACK_HOME, False): + latitude = self.hass.config.latitude + longitude = self.hass.config.longitude + elevation = self.hass.config.elevation + else: + latitude = self._config[CONF_LATITUDE] + longitude = self._config[CONF_LONGITUDE] + elevation = self._config[CONF_ELEVATION] + + coordinates = { + "lat": str(latitude), + "lon": str(longitude), + "msl": str(elevation), + } + if coordinates == self._coordinates: + return False + self._coordinates = coordinates + + self._weather_data = metno.MetWeatherData( + coordinates, async_get_clientsession(self.hass), api_url=URL + ) + return True + + async def fetch_data(self) -> Self: + """Fetch data from API - (current weather and forecast).""" + resp = await self._weather_data.fetching_data() + if not resp: + raise CannotConnect() + self.current_weather_data = self._weather_data.get_current_weather() + time_zone = dt_util.DEFAULT_TIME_ZONE + self.daily_forecast = self._weather_data.get_forecast(time_zone, False, 0) + self.hourly_forecast = self._weather_data.get_forecast(time_zone, True) + return self + + +class MetDataUpdateCoordinator(DataUpdateCoordinator[MetWeatherData]): + """Class to manage fetching Met data.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize global Met data updater.""" + self._unsub_track_home: Callable[[], None] | None = None + self.weather = MetWeatherData(hass, config_entry.data) + self.weather.set_coordinates() + + update_interval = timedelta(minutes=randrange(55, 65)) + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + + async def _async_update_data(self) -> MetWeatherData: + """Fetch data from Met.""" + try: + return await self.weather.fetch_data() + except Exception as err: + raise UpdateFailed(f"Update failed: {err}") from err + + def track_home(self) -> None: + """Start tracking changes to HA home setting.""" + if self._unsub_track_home: + return + + async def _async_update_weather_data(_event: Event | None = None) -> None: + """Update weather data.""" + if self.weather.set_coordinates(): + await self.async_refresh() + + self._unsub_track_home = self.hass.bus.async_listen( + EVENT_CORE_CONFIG_UPDATE, _async_update_weather_data + ) + + def untrack_home(self) -> None: + """Stop tracking changes to HA home setting.""" + if self._unsub_track_home: + self._unsub_track_home() + self._unsub_track_home = None diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index 97b99e826cd..11b044311d2 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -36,7 +36,6 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_system import METRIC_SYSTEM -from . import MetDataUpdateCoordinator from .const import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_SUNNY, @@ -46,6 +45,7 @@ from .const import ( DOMAIN, FORECAST_MAP, ) +from .coordinator import MetDataUpdateCoordinator DEFAULT_NAME = "Met.no" diff --git a/homeassistant/components/mikrotik/const.py b/homeassistant/components/mikrotik/const.py index 4354b9b06bd..8407dd14a6e 100644 --- a/homeassistant/components/mikrotik/const.py +++ b/homeassistant/components/mikrotik/const.py @@ -25,9 +25,11 @@ CAPSMAN: Final = "capsman" DHCP: Final = "dhcp" WIRELESS: Final = "wireless" WIFIWAVE2: Final = "wifiwave2" +WIFI: Final = "wifi" IS_WIRELESS: Final = "is_wireless" IS_CAPSMAN: Final = "is_capsman" IS_WIFIWAVE2: Final = "is_wifiwave2" +IS_WIFI: Final = "is_wifi" MIKROTIK_SERVICES: Final = { @@ -38,9 +40,11 @@ MIKROTIK_SERVICES: Final = { INFO: "/system/routerboard/getall", WIRELESS: "/interface/wireless/registration-table/getall", WIFIWAVE2: "/interface/wifiwave2/registration-table/print", + WIFI: "/interface/wifi/registration-table/print", IS_WIRELESS: "/interface/wireless/print", IS_CAPSMAN: "/caps-man/interface/print", IS_WIFIWAVE2: "/interface/wifiwave2/print", + IS_WIFI: "/interface/wifi/print", } diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index 9e0a610c770..af7dfb2ab2c 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -31,10 +31,12 @@ from .const import ( IDENTITY, INFO, IS_CAPSMAN, + IS_WIFI, IS_WIFIWAVE2, IS_WIRELESS, MIKROTIK_SERVICES, NAME, + WIFI, WIFIWAVE2, WIRELESS, ) @@ -60,6 +62,7 @@ class MikrotikData: self.support_capsman: bool = False self.support_wireless: bool = False self.support_wifiwave2: bool = False + self.support_wifi: bool = False self.hostname: str = "" self.model: str = "" self.firmware: str = "" @@ -101,6 +104,7 @@ class MikrotikData: self.support_capsman = bool(self.command(MIKROTIK_SERVICES[IS_CAPSMAN])) self.support_wireless = bool(self.command(MIKROTIK_SERVICES[IS_WIRELESS])) self.support_wifiwave2 = bool(self.command(MIKROTIK_SERVICES[IS_WIFIWAVE2])) + self.support_wifi = bool(self.command(MIKROTIK_SERVICES[IS_WIFI])) def get_list_from_interface(self, interface: str) -> dict[str, dict[str, Any]]: """Get devices from interface.""" @@ -128,6 +132,9 @@ class MikrotikData: elif self.support_wifiwave2: _LOGGER.debug("Hub supports wifiwave2 Interface") device_list = wireless_devices = self.get_list_from_interface(WIFIWAVE2) + elif self.support_wifi: + _LOGGER.debug("Hub supports wifi Interface") + device_list = wireless_devices = self.get_list_from_interface(WIFI) if not device_list or self.force_dhcp: device_list = self.all_devices diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index cb0ba4522bf..7bb78eb05e7 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/mill", "iot_class": "local_polling", "loggers": ["mill", "mill_local"], - "requirements": ["millheater==0.11.6", "mill-local==0.3.0"] + "requirements": ["millheater==0.11.7", "mill-local==0.3.0"] } diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index e9bb3af51f2..9265b72d0d0 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -36,45 +36,49 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -def setup_decrypt(key_encoder) -> tuple[int, Callable]: +def setup_decrypt( + key_encoder: type[RawEncoder] | type[HexEncoder], +) -> Callable[[bytes, bytes], bytes]: """Return decryption function and length of key. Async friendly. """ - def decrypt(ciphertext, key): + def decrypt(ciphertext: bytes, key: bytes) -> bytes: """Decrypt ciphertext using key.""" return SecretBox(key, encoder=key_encoder).decrypt( ciphertext, encoder=Base64Encoder ) - return (SecretBox.KEY_SIZE, decrypt) + return decrypt -def setup_encrypt(key_encoder) -> tuple[int, Callable]: +def setup_encrypt( + key_encoder: type[RawEncoder] | type[HexEncoder], +) -> Callable[[bytes, bytes], bytes]: """Return encryption function and length of key. Async friendly. """ - def encrypt(ciphertext, key): + def encrypt(ciphertext: bytes, key: bytes) -> bytes: """Encrypt ciphertext using key.""" return SecretBox(key, encoder=key_encoder).encrypt( ciphertext, encoder=Base64Encoder ) - return (SecretBox.KEY_SIZE, encrypt) + return encrypt def _decrypt_payload_helper( - key: str | None, - ciphertext: str, - get_key_bytes: Callable[[str, int], str | bytes], - key_encoder, + key: str | bytes, + ciphertext: bytes, + key_bytes: bytes, + key_encoder: type[RawEncoder] | type[HexEncoder], ) -> JsonValueType | None: """Decrypt encrypted payload.""" try: - keylen, decrypt = setup_decrypt(key_encoder) + decrypt = setup_decrypt(key_encoder) except OSError: _LOGGER.warning("Ignoring encrypted payload because libsodium not installed") return None @@ -83,33 +87,31 @@ def _decrypt_payload_helper( _LOGGER.warning("Ignoring encrypted payload because no decryption key known") return None - key_bytes = get_key_bytes(key, keylen) - msg_bytes = decrypt(ciphertext, key_bytes) message = json_loads(msg_bytes) _LOGGER.debug("Successfully decrypted mobile_app payload") return message -def decrypt_payload(key: str | None, ciphertext: str) -> JsonValueType | None: +def decrypt_payload(key: str, ciphertext: bytes) -> JsonValueType | None: """Decrypt encrypted payload.""" - - def get_key_bytes(key: str, keylen: int) -> str: - return key - - return _decrypt_payload_helper(key, ciphertext, get_key_bytes, HexEncoder) + return _decrypt_payload_helper(key, ciphertext, key.encode("utf-8"), HexEncoder) -def decrypt_payload_legacy(key: str | None, ciphertext: str) -> JsonValueType | None: +def _convert_legacy_encryption_key(key: str) -> bytes: + """Convert legacy encryption key.""" + keylen = SecretBox.KEY_SIZE + key_bytes = key.encode("utf-8") + key_bytes = key_bytes[:keylen] + key_bytes = key_bytes.ljust(keylen, b"\0") + return key_bytes + + +def decrypt_payload_legacy(key: str, ciphertext: bytes) -> JsonValueType | None: """Decrypt encrypted payload.""" - - def get_key_bytes(key: str, keylen: int) -> bytes: - key_bytes = key.encode("utf-8") - key_bytes = key_bytes[:keylen] - key_bytes = key_bytes.ljust(keylen, b"\0") - return key_bytes - - return _decrypt_payload_helper(key, ciphertext, get_key_bytes, RawEncoder) + return _decrypt_payload_helper( + key, ciphertext, _convert_legacy_encryption_key(key), RawEncoder + ) def registration_context(registration: Mapping[str, Any]) -> Context: @@ -184,16 +186,14 @@ def webhook_response( json_data = json_bytes(data) if registration[ATTR_SUPPORTS_ENCRYPTION]: - keylen, encrypt = setup_encrypt( + encrypt = setup_encrypt( HexEncoder if ATTR_NO_LEGACY_ENCRYPTION in registration else RawEncoder ) if ATTR_NO_LEGACY_ENCRYPTION in registration: key: bytes = registration[CONF_SECRET] else: - key = registration[CONF_SECRET].encode("utf-8") - key = key[:keylen] - key = key.ljust(keylen, b"\0") + key = _convert_legacy_encryption_key(registration[CONF_SECRET]) enc_data = encrypt(json_data, key).decode("utf-8") json_data = json_bytes({"encrypted": True, "encrypted_data": enc_data}) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index a2b0c24464c..14f8b59ddee 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -162,11 +162,9 @@ BASE_STRUCT_SCHEMA = BASE_COMPONENT_SCHEMA.extend( vol.Optional(CONF_COUNT): cv.positive_int, vol.Optional(CONF_DATA_TYPE, default=DataType.INT16): vol.In( [ - DataType.INT8, DataType.INT16, DataType.INT32, DataType.INT64, - DataType.UINT8, DataType.UINT16, DataType.UINT32, DataType.UINT64, diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 92a38bb5e92..a52f8ccfc97 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -85,11 +85,9 @@ class DataType(str, Enum): CUSTOM = "custom" STRING = "string" - INT8 = "int8" INT16 = "int16" INT32 = "int32" INT64 = "int64" - UINT8 = "uint8" UINT16 = "uint16" UINT32 = "uint32" UINT64 = "uint64" diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 764cf4930f7..c0474ad75d5 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -435,16 +435,24 @@ class ModbusHub: try: result: ModbusResponse = entry.func(address, value, **kwargs) except ModbusException as exception_error: - self._log_error(str(exception_error)) + error = ( + f"Error: device: {slave} address: {address} -> {str(exception_error)}" + ) + self._log_error(error) return None if not result: - self._log_error("Error: pymodbus returned None") + error = ( + f"Error: device: {slave} address: {address} -> pymodbus returned None" + ) + self._log_error(error) return None if not hasattr(result, entry.attr): - self._log_error(str(result)) + error = f"Error: device: {slave} address: {address} -> {str(result)}" + self._log_error(error) return None if result.isError(): - self._log_error("Error: pymodbus returned isError True") + error = f"Error: device: {slave} address: {address} -> pymodbus returned isError True" + self._log_error(error) return None self._in_error = False return result diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index 5fa314d589c..52919a24ac7 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -52,6 +52,12 @@ ENTRY = namedtuple( "validate_parm", ], ) + + +ILLEGAL = "I" +OPTIONAL = "O" +DEMANDED = "D" + PARM_IS_LEGAL = namedtuple( "PARM_IS_LEGAL", [ @@ -62,30 +68,40 @@ PARM_IS_LEGAL = namedtuple( "swap_word", ], ) -# PARM_IS_LEGAL defines if the keywords: -# count: -# structure: -# swap: byte -# swap: word -# swap: word_byte (identical to swap: word) -# are legal to use. -# These keywords are only legal with some datatype: ... -# As expressed in DEFAULT_STRUCT_FORMAT - DEFAULT_STRUCT_FORMAT = { - DataType.INT8: ENTRY("b", 1, PARM_IS_LEGAL(False, False, False, False, False)), - DataType.UINT8: ENTRY("c", 1, PARM_IS_LEGAL(False, False, False, False, False)), - DataType.INT16: ENTRY("h", 1, PARM_IS_LEGAL(False, False, True, True, False)), - DataType.UINT16: ENTRY("H", 1, PARM_IS_LEGAL(False, False, True, True, False)), - DataType.FLOAT16: ENTRY("e", 1, PARM_IS_LEGAL(False, False, True, True, False)), - DataType.INT32: ENTRY("i", 2, PARM_IS_LEGAL(False, False, True, True, True)), - DataType.UINT32: ENTRY("I", 2, PARM_IS_LEGAL(False, False, True, True, True)), - DataType.FLOAT32: ENTRY("f", 2, PARM_IS_LEGAL(False, False, True, True, True)), - DataType.INT64: ENTRY("q", 4, PARM_IS_LEGAL(False, False, True, True, True)), - DataType.UINT64: ENTRY("Q", 4, PARM_IS_LEGAL(False, False, True, True, True)), - DataType.FLOAT64: ENTRY("d", 4, PARM_IS_LEGAL(False, False, True, True, True)), - DataType.STRING: ENTRY("s", -1, PARM_IS_LEGAL(True, False, False, True, False)), - DataType.CUSTOM: ENTRY("?", 0, PARM_IS_LEGAL(True, True, False, False, False)), + DataType.INT16: ENTRY( + "h", 1, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, ILLEGAL) + ), + DataType.UINT16: ENTRY( + "H", 1, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, ILLEGAL) + ), + DataType.FLOAT16: ENTRY( + "e", 1, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, ILLEGAL) + ), + DataType.INT32: ENTRY( + "i", 2, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, OPTIONAL) + ), + DataType.UINT32: ENTRY( + "I", 2, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, OPTIONAL) + ), + DataType.FLOAT32: ENTRY( + "f", 2, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, OPTIONAL) + ), + DataType.INT64: ENTRY( + "q", 4, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, OPTIONAL) + ), + DataType.UINT64: ENTRY( + "Q", 4, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, OPTIONAL) + ), + DataType.FLOAT64: ENTRY( + "d", 4, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, OPTIONAL) + ), + DataType.STRING: ENTRY( + "s", 0, PARM_IS_LEGAL(DEMANDED, ILLEGAL, ILLEGAL, OPTIONAL, ILLEGAL) + ), + DataType.CUSTOM: ENTRY( + "?", 0, PARM_IS_LEGAL(DEMANDED, DEMANDED, ILLEGAL, ILLEGAL, ILLEGAL) + ), } @@ -98,37 +114,37 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: data_type = config[CONF_DATA_TYPE] = DataType.INT16 count = config.get(CONF_COUNT, None) structure = config.get(CONF_STRUCTURE, None) - slave_count = config.get(CONF_SLAVE_COUNT, None) - slave_name = CONF_SLAVE_COUNT - if not slave_count: - slave_count = config.get(CONF_VIRTUAL_COUNT, 0) - slave_name = CONF_VIRTUAL_COUNT + slave_count = config.get(CONF_SLAVE_COUNT, config.get(CONF_VIRTUAL_COUNT)) swap_type = config.get(CONF_SWAP, CONF_SWAP_NONE) validator = DEFAULT_STRUCT_FORMAT[data_type].validate_parm - if count and not validator.count: - error = f"{name}: `{CONF_COUNT}: {count}` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" - raise vol.Invalid(error) - if not count and validator.count: - error = f"{name}: `{CONF_COUNT}:` missing, demanded with `{CONF_DATA_TYPE}: {data_type}`" - raise vol.Invalid(error) - if structure and not validator.structure: - error = f"{name}: `{CONF_STRUCTURE}: {structure}` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" - raise vol.Invalid(error) - if not structure and validator.structure: - error = f"{name}: `{CONF_STRUCTURE}` missing or empty, demanded with `{CONF_DATA_TYPE}: {data_type}`" - raise vol.Invalid(error) - if slave_count and not validator.slave_count: - error = f"{name}: `{slave_name}: {slave_count}` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" - raise vol.Invalid(error) + for entry in ( + (count, validator.count, CONF_COUNT), + (structure, validator.structure, CONF_STRUCTURE), + ( + slave_count, + validator.slave_count, + f"{CONF_VIRTUAL_COUNT} / {CONF_SLAVE_COUNT}", + ), + ): + if entry[0] is None: + if entry[1] == DEMANDED: + error = f"{name}: `{entry[2]}:` missing, demanded with `{CONF_DATA_TYPE}: {data_type}`" + raise vol.Invalid(error) + elif entry[1] == ILLEGAL: + error = ( + f"{name}: `{entry[2]}:` illegal with `{CONF_DATA_TYPE}: {data_type}`" + ) + raise vol.Invalid(error) + if swap_type != CONF_SWAP_NONE: swap_type_validator = { - CONF_SWAP_NONE: False, + CONF_SWAP_NONE: validator.swap_byte, CONF_SWAP_BYTE: validator.swap_byte, CONF_SWAP_WORD: validator.swap_word, CONF_SWAP_WORD_BYTE: validator.swap_word, }[swap_type] - if not swap_type_validator: - error = f"{name}: `{CONF_SWAP}:{swap_type}` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" + if swap_type_validator == ILLEGAL: + error = f"{name}: `{CONF_SWAP}:{swap_type}` illegal with `{CONF_DATA_TYPE}: {data_type}`" raise vol.Invalid(error) if config[CONF_DATA_TYPE] == DataType.CUSTOM: try: diff --git a/homeassistant/components/modern_forms/strings.json b/homeassistant/components/modern_forms/strings.json index dd47ef721af..e6d0f6a2206 100644 --- a/homeassistant/components/modern_forms/strings.json +++ b/homeassistant/components/modern_forms/strings.json @@ -6,6 +6,9 @@ "description": "Set up your Modern Forms fan to integrate with Home Assistant.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Modern Forms fan." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/moehlenhoff_alpha2/strings.json b/homeassistant/components/moehlenhoff_alpha2/strings.json index 3347b2f318c..d15ec9f89eb 100644 --- a/homeassistant/components/moehlenhoff_alpha2/strings.json +++ b/homeassistant/components/moehlenhoff_alpha2/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Möhlenhoff Alpha2 system." } } }, diff --git a/homeassistant/components/mopeka/manifest.json b/homeassistant/components/mopeka/manifest.json index d6b5618bf97..766af715485 100644 --- a/homeassistant/components/mopeka/manifest.json +++ b/homeassistant/components/mopeka/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/mopeka", "integration_type": "device", "iot_class": "local_push", - "requirements": ["mopeka-iot-ble==0.4.1"] + "requirements": ["mopeka-iot-ble==0.5.0"] } diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index d8dc25e0006..e71abe09069 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -48,6 +48,7 @@ class MotionBatterySensor(MotionCoordinatorEntity, SensorEntity): _attr_device_class = SensorDeviceClass.BATTERY _attr_native_unit_of_measurement = PERCENTAGE + _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__(self, coordinator, blind): """Initialize the Motion Battery Sensor.""" diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index 8eab83b5d41..9b3adb38e0c 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -1,11 +1,13 @@ """Support to interact with a Music Player Daemon.""" from __future__ import annotations -from contextlib import suppress +import asyncio +from contextlib import asynccontextmanager, suppress from datetime import timedelta import hashlib import logging import os +from socket import gaierror from typing import Any import mpd @@ -92,11 +94,11 @@ class MpdDevice(MediaPlayerEntity): self._name = name self.password = password - self._status = None + self._status = {} self._currentsong = None self._playlists = None self._currentplaylist = None - self._is_connected = False + self._is_available = None self._muted = False self._muted_volume = None self._media_position_updated_at = None @@ -104,67 +106,88 @@ class MpdDevice(MediaPlayerEntity): self._media_image_hash = None # Track if the song changed so image doesn't have to be loaded every update. self._media_image_file = None - self._commands = None # set up MPD client self._client = MPDClient() self._client.timeout = 30 - self._client.idletimeout = None + self._client.idletimeout = 10 + self._client_lock = asyncio.Lock() - async def _connect(self): - """Connect to MPD.""" - try: - await self._client.connect(self.server, self.port) - - if self.password is not None: - await self._client.password(self.password) - except mpd.ConnectionError: - return - - self._is_connected = True - - def _disconnect(self): - """Disconnect from MPD.""" - with suppress(mpd.ConnectionError): - self._client.disconnect() - self._is_connected = False - self._status = None - - async def _fetch_status(self): - """Fetch status from MPD.""" - self._status = await self._client.status() - self._currentsong = await self._client.currentsong() - await self._async_update_media_image_hash() - - if (position := self._status.get("elapsed")) is None: - position = self._status.get("time") - - if isinstance(position, str) and ":" in position: - position = position.split(":")[0] - - if position is not None and self._media_position != position: - self._media_position_updated_at = dt_util.utcnow() - self._media_position = int(float(position)) - - await self._update_playlists() - - @property - def available(self): - """Return true if MPD is available and connected.""" - return self._is_connected + # Instead of relying on python-mpd2 to maintain a (persistent) connection to + # MPD, the below explicitly sets up a *non*-persistent connection. This is + # done to workaround the issue as described in: + # + @asynccontextmanager + async def connection(self): + """Handle MPD connect and disconnect.""" + async with self._client_lock: + try: + # MPDClient.connect() doesn't always respect its timeout. To + # prevent a deadlock, enforce an additional (slightly longer) + # timeout on the coroutine itself. + try: + async with asyncio.timeout(self._client.timeout + 5): + await self._client.connect(self.server, self.port) + except asyncio.TimeoutError as error: + # TimeoutError has no message (which hinders logging further + # down the line), so provide one. + raise asyncio.TimeoutError( + "Connection attempt timed out" + ) from error + if self.password is not None: + await self._client.password(self.password) + self._is_available = True + yield + except ( + asyncio.TimeoutError, + gaierror, + mpd.ConnectionError, + OSError, + ) as error: + # Log a warning during startup or when previously connected; for + # subsequent errors a debug message is sufficient. + log_level = logging.DEBUG + if self._is_available is not False: + log_level = logging.WARNING + _LOGGER.log( + log_level, "Error connecting to '%s': %s", self.server, error + ) + self._is_available = False + self._status = {} + # Also yield on failure. Handling mpd.ConnectionErrors caused by + # attempting to control a disconnected client is the + # responsibility of the caller. + yield + finally: + with suppress(mpd.ConnectionError): + self._client.disconnect() async def async_update(self) -> None: - """Get the latest data and update the state.""" - try: - if not self._is_connected: - await self._connect() - self._commands = list(await self._client.commands()) + """Get the latest data from MPD and update the state.""" + async with self.connection(): + try: + self._status = await self._client.status() + self._currentsong = await self._client.currentsong() + await self._async_update_media_image_hash() - await self._fetch_status() - except (mpd.ConnectionError, OSError, ValueError) as error: - # Cleanly disconnect in case connection is not in valid state - _LOGGER.debug("Error updating status: %s", error) - self._disconnect() + if (position := self._status.get("elapsed")) is None: + position = self._status.get("time") + + if isinstance(position, str) and ":" in position: + position = position.split(":")[0] + + if position is not None and self._media_position != position: + self._media_position_updated_at = dt_util.utcnow() + self._media_position = int(float(position)) + + await self._update_playlists() + except (mpd.ConnectionError, ValueError) as error: + _LOGGER.debug("Error updating status: %s", error) + + @property + def available(self) -> bool: + """Return true if MPD is available and connected.""" + return self._is_available is True @property def name(self): @@ -174,13 +197,13 @@ class MpdDevice(MediaPlayerEntity): @property def state(self) -> MediaPlayerState: """Return the media state.""" - if self._status is None: + if not self._status: return MediaPlayerState.OFF - if self._status["state"] == "play": + if self._status.get("state") == "play": return MediaPlayerState.PLAYING - if self._status["state"] == "pause": + if self._status.get("state") == "pause": return MediaPlayerState.PAUSED - if self._status["state"] == "stop": + if self._status.get("state") == "stop": return MediaPlayerState.OFF return MediaPlayerState.OFF @@ -259,20 +282,26 @@ class MpdDevice(MediaPlayerEntity): async def async_get_media_image(self) -> tuple[bytes | None, str | None]: """Fetch media image of current playing track.""" - if not (file := self._currentsong.get("file")): - return None, None - response = await self._async_get_file_image_response(file) - if response is None: - return None, None + async with self.connection(): + if self._currentsong is None or not (file := self._currentsong.get("file")): + return None, None - image = bytes(response["binary"]) - mime = response.get( - "type", "image/png" - ) # readpicture has type, albumart does not - return (image, mime) + with suppress(mpd.ConnectionError): + response = await self._async_get_file_image_response(file) + if response is None: + return None, None + + image = bytes(response["binary"]) + mime = response.get( + "type", "image/png" + ) # readpicture has type, albumart does not + return (image, mime) async def _async_update_media_image_hash(self): """Update the hash value for the media image.""" + if self._currentsong is None: + return + file = self._currentsong.get("file") if file == self._media_image_file: @@ -295,16 +324,21 @@ class MpdDevice(MediaPlayerEntity): self._media_image_file = file async def _async_get_file_image_response(self, file): - # not all MPD implementations and versions support the `albumart` and `fetchpicture` commands - can_albumart = "albumart" in self._commands - can_readpicture = "readpicture" in self._commands + # not all MPD implementations and versions support the `albumart` and + # `fetchpicture` commands. + commands = [] + with suppress(mpd.ConnectionError): + commands = list(await self._client.commands()) + can_albumart = "albumart" in commands + can_readpicture = "readpicture" in commands response = None # read artwork embedded into the media file if can_readpicture: try: - response = await self._client.readpicture(file) + with suppress(mpd.ConnectionError): + response = await self._client.readpicture(file) except mpd.CommandError as error: if error.errno is not mpd.FailureResponseCode.NO_EXIST: _LOGGER.warning( @@ -315,7 +349,8 @@ class MpdDevice(MediaPlayerEntity): # read artwork contained in the media directory (cover.{jpg,png,tiff,bmp}) if none is embedded if can_albumart and not response: try: - response = await self._client.albumart(file) + with suppress(mpd.ConnectionError): + response = await self._client.albumart(file) except mpd.CommandError as error: if error.errno is not mpd.FailureResponseCode.NO_EXIST: _LOGGER.warning( @@ -339,7 +374,7 @@ class MpdDevice(MediaPlayerEntity): @property def supported_features(self) -> MediaPlayerEntityFeature: """Flag media player features that are supported.""" - if self._status is None: + if not self._status: return MediaPlayerEntityFeature(0) supported = SUPPORT_MPD @@ -373,55 +408,64 @@ class MpdDevice(MediaPlayerEntity): """Update available MPD playlists.""" try: self._playlists = [] - for playlist_data in await self._client.listplaylists(): - self._playlists.append(playlist_data["playlist"]) + with suppress(mpd.ConnectionError): + for playlist_data in await self._client.listplaylists(): + self._playlists.append(playlist_data["playlist"]) except mpd.CommandError as error: self._playlists = None _LOGGER.warning("Playlists could not be updated: %s:", error) async def async_set_volume_level(self, volume: float) -> None: """Set volume of media player.""" - if "volume" in self._status: - await self._client.setvol(int(volume * 100)) + async with self.connection(): + if "volume" in self._status: + await self._client.setvol(int(volume * 100)) async def async_volume_up(self) -> None: """Service to send the MPD the command for volume up.""" - if "volume" in self._status: - current_volume = int(self._status["volume"]) + async with self.connection(): + if "volume" in self._status: + current_volume = int(self._status["volume"]) - if current_volume <= 100: - self._client.setvol(current_volume + 5) + if current_volume <= 100: + self._client.setvol(current_volume + 5) async def async_volume_down(self) -> None: """Service to send the MPD the command for volume down.""" - if "volume" in self._status: - current_volume = int(self._status["volume"]) + async with self.connection(): + if "volume" in self._status: + current_volume = int(self._status["volume"]) - if current_volume >= 0: - await self._client.setvol(current_volume - 5) + if current_volume >= 0: + await self._client.setvol(current_volume - 5) async def async_media_play(self) -> None: """Service to send the MPD the command for play/pause.""" - if self._status["state"] == "pause": - await self._client.pause(0) - else: - await self._client.play() + async with self.connection(): + if self._status.get("state") == "pause": + await self._client.pause(0) + else: + await self._client.play() async def async_media_pause(self) -> None: """Service to send the MPD the command for play/pause.""" - await self._client.pause(1) + async with self.connection(): + await self._client.pause(1) async def async_media_stop(self) -> None: """Service to send the MPD the command for stop.""" - await self._client.stop() + async with self.connection(): + await self._client.stop() async def async_media_next_track(self) -> None: """Service to send the MPD the command for next track.""" - await self._client.next() + async with self.connection(): + await self._client.next() async def async_media_previous_track(self) -> None: """Service to send the MPD the command for previous track.""" - await self._client.previous() + async with self.connection(): + await self._client.previous() async def async_mute_volume(self, mute: bool) -> None: """Mute. Emulated with set_volume_level.""" @@ -437,75 +481,82 @@ class MpdDevice(MediaPlayerEntity): self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Send the media player the command for playing a playlist.""" - if media_source.is_media_source_id(media_id): - media_type = MediaType.MUSIC - play_item = await media_source.async_resolve_media( - self.hass, media_id, self.entity_id - ) - media_id = async_process_play_media_url(self.hass, play_item.url) + async with self.connection(): + if media_source.is_media_source_id(media_id): + media_type = MediaType.MUSIC + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) + media_id = async_process_play_media_url(self.hass, play_item.url) - if media_type == MediaType.PLAYLIST: - _LOGGER.debug("Playing playlist: %s", media_id) - if media_id in self._playlists: - self._currentplaylist = media_id + if media_type == MediaType.PLAYLIST: + _LOGGER.debug("Playing playlist: %s", media_id) + if media_id in self._playlists: + self._currentplaylist = media_id + else: + self._currentplaylist = None + _LOGGER.warning("Unknown playlist name %s", media_id) + await self._client.clear() + await self._client.load(media_id) + await self._client.play() else: + await self._client.clear() self._currentplaylist = None - _LOGGER.warning("Unknown playlist name %s", media_id) - await self._client.clear() - await self._client.load(media_id) - await self._client.play() - else: - await self._client.clear() - self._currentplaylist = None - await self._client.add(media_id) - await self._client.play() + await self._client.add(media_id) + await self._client.play() @property def repeat(self) -> RepeatMode: """Return current repeat mode.""" - if self._status["repeat"] == "1": - if self._status["single"] == "1": + if self._status.get("repeat") == "1": + if self._status.get("single") == "1": return RepeatMode.ONE return RepeatMode.ALL return RepeatMode.OFF async def async_set_repeat(self, repeat: RepeatMode) -> None: """Set repeat mode.""" - if repeat == RepeatMode.OFF: - await self._client.repeat(0) - await self._client.single(0) - else: - await self._client.repeat(1) - if repeat == RepeatMode.ONE: - await self._client.single(1) - else: + async with self.connection(): + if repeat == RepeatMode.OFF: + await self._client.repeat(0) await self._client.single(0) + else: + await self._client.repeat(1) + if repeat == RepeatMode.ONE: + await self._client.single(1) + else: + await self._client.single(0) @property def shuffle(self): """Boolean if shuffle is enabled.""" - return bool(int(self._status["random"])) + return bool(int(self._status.get("random"))) async def async_set_shuffle(self, shuffle: bool) -> None: """Enable/disable shuffle mode.""" - await self._client.random(int(shuffle)) + async with self.connection(): + await self._client.random(int(shuffle)) async def async_turn_off(self) -> None: """Service to send the MPD the command to stop playing.""" - await self._client.stop() + async with self.connection(): + await self._client.stop() async def async_turn_on(self) -> None: """Service to send the MPD the command to start playing.""" - await self._client.play() - await self._update_playlists(no_throttle=True) + async with self.connection(): + await self._client.play() + await self._update_playlists(no_throttle=True) async def async_clear_playlist(self) -> None: """Clear players playlist.""" - await self._client.clear() + async with self.connection(): + await self._client.clear() async def async_media_seek(self, position: float) -> None: """Send seek command.""" - await self._client.seekcur(position) + async with self.connection(): + await self._client.seekcur(position) async def async_browse_media( self, @@ -513,8 +564,11 @@ class MpdDevice(MediaPlayerEntity): media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" - return await media_source.async_browse_media( - self.hass, - media_content_id, - content_filter=lambda item: item.media_content_type.startswith("audio/"), - ) + async with self.connection(): + return await media_source.async_browse_media( + self.hass, + media_content_id, + content_filter=lambda item: item.media_content_type.startswith( + "audio/" + ), + ) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index be283271dee..16f584db011 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -24,7 +24,12 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError, TemplateError, Unauthorized +from homeassistant.exceptions import ( + ConfigValidationError, + ServiceValidationError, + TemplateError, + Unauthorized, +) from homeassistant.helpers import config_validation as cv, event as ev, template from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -240,13 +245,20 @@ async def async_check_config_schema( for config in config_items: try: schema(config) - except vol.Invalid as ex: + except vol.Invalid as exc: integration = await async_get_integration(hass, DOMAIN) # pylint: disable-next=protected-access - message, _ = conf_util._format_config_error( - ex, domain, config, integration.documentation + message = conf_util.format_schema_error( + hass, exc, domain, config, integration.documentation ) - raise HomeAssistantError(message) from ex + raise ServiceValidationError( + message, + translation_domain=DOMAIN, + translation_key="invalid_platform_config", + translation_placeholders={ + "domain": domain, + }, + ) from exc async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -405,14 +417,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _reload_config(call: ServiceCall) -> None: """Reload the platforms.""" # Fetch updated manually configured items and validate - if ( - config_yaml := await async_integration_yaml_config(hass, DOMAIN) - ) is None: - # Raise in case we have an invalid configuration - raise HomeAssistantError( - "Error reloading manually configured MQTT items, " - "check your configuration.yaml" + try: + config_yaml = await async_integration_yaml_config( + hass, DOMAIN, raise_on_failure=True ) + except ConfigValidationError as ex: + raise ServiceValidationError( + str(ex), + translation_domain=ex.translation_domain, + translation_key=ex.translation_key, + translation_placeholders=ex.translation_placeholders, + ) from ex + # Check the schema before continuing reload await async_check_config_schema(hass, config_yaml) diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 7ab2e9ebf90..9143b804c60 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -42,6 +42,7 @@ from .mixins import ( MqttAvailability, MqttEntity, async_setup_entity_entry_helper, + validate_sensor_entity_category, write_state_on_attr_change, ) from .models import MqttValueTemplate, ReceiveMessage @@ -55,7 +56,7 @@ DEFAULT_PAYLOAD_ON = "ON" DEFAULT_FORCE_UPDATE = False CONF_EXPIRE_AFTER = "expire_after" -PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend( +_PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( { vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None), vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, @@ -67,7 +68,15 @@ PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) +DISCOVERY_SCHEMA = vol.All( + validate_sensor_entity_category(binary_sensor.DOMAIN, discovery=True), + _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), +) + +PLATFORM_SCHEMA_MODERN = vol.All( + validate_sensor_entity_category(binary_sensor.DOMAIN, discovery=False), + _PLATFORM_SCHEMA_BASE, +) async def async_setup_entry( diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 2e4d49b4cd9..c87d4c9244a 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -124,7 +124,10 @@ async def async_publish( """Publish message to a MQTT topic.""" if not mqtt_config_entry_enabled(hass): raise HomeAssistantError( - f"Cannot publish to topic '{topic}', MQTT is not enabled" + f"Cannot publish to topic '{topic}', MQTT is not enabled", + translation_key="mqtt_not_setup_cannot_publish", + translation_domain=DOMAIN, + translation_placeholders={"topic": topic}, ) mqtt_data = get_mqtt_data(hass) outgoing_payload = payload @@ -174,15 +177,21 @@ async def async_subscribe( """ if not mqtt_config_entry_enabled(hass): raise HomeAssistantError( - f"Cannot subscribe to topic '{topic}', MQTT is not enabled" + f"Cannot subscribe to topic '{topic}', MQTT is not enabled", + translation_key="mqtt_not_setup_cannot_subscribe", + translation_domain=DOMAIN, + translation_placeholders={"topic": topic}, ) try: mqtt_data = get_mqtt_data(hass) - except KeyError as ex: + except KeyError as exc: raise HomeAssistantError( f"Cannot subscribe to topic '{topic}', " - "make sure MQTT is set up correctly" - ) from ex + "make sure MQTT is set up correctly", + translation_key="mqtt_not_setup_cannot_subscribe", + translation_domain=DOMAIN, + translation_placeholders={"topic": topic}, + ) from exc async_remove = await mqtt_data.client.async_subscribe( topic, catch_log_exception( @@ -606,8 +615,8 @@ class MQTT: del simple_subscriptions[topic] else: self._wildcard_subscriptions.remove(subscription) - except (KeyError, ValueError) as ex: - raise HomeAssistantError("Can't remove subscription twice") from ex + except (KeyError, ValueError) as exc: + raise HomeAssistantError("Can't remove subscription twice") from exc @callback def _async_queue_subscriptions( diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 3fa3ebfd30c..c8696071fb4 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -470,9 +470,10 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): except ValueError: _LOGGER.error("Could not parse %s from %s", template_name, payload) - def prepare_subscribe_topics( - self, topics: dict[str, dict[str, Any]] - ) -> None: # noqa: C901 + def prepare_subscribe_topics( # noqa: C901 + self, + topics: dict[str, dict[str, Any]], + ) -> None: """(Re)Subscribe to topics.""" @callback diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index c8da14e67e6..4e8cf0f4129 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -380,7 +380,11 @@ class MqttCover(MqttEntity, CoverEntity): else STATE_OPEN ) else: - state = STATE_CLOSED if self.state == STATE_CLOSING else STATE_OPEN + state = ( + STATE_CLOSED + if self.state in [STATE_CLOSED, STATE_CLOSING] + else STATE_OPEN + ) elif payload == self._config[CONF_STATE_OPENING]: state = STATE_OPENING elif payload == self._config[CONF_STATE_CLOSING]: diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 0e9e7d708e9..e3dcf66c8b1 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -553,8 +553,6 @@ class MqttFan(MqttEntity, FanEntity): This method is a coroutine. """ - self._valid_preset_mode_or_raise(preset_mode) - mqtt_payload = self._command_templates[ATTR_PRESET_MODE](preset_mode) await self.async_publish( diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 2a2a262be36..3d2957f153d 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -367,13 +367,10 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): if brightness_supported(self.supported_color_modes): try: if brightness := values["brightness"]: + scale = self._config[CONF_BRIGHTNESS_SCALE] self._attr_brightness = min( - int( - brightness # type: ignore[operator] - / float(self._config[CONF_BRIGHTNESS_SCALE]) - * 255 - ), 255, + round(brightness * 255 / scale), # type: ignore[operator] ) else: _LOGGER.debug( diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 91a5511001b..76300afb97a 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -29,7 +29,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, EntityCategory, ) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, async_get_hass, callback from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -208,14 +208,60 @@ def validate_device_has_at_least_one_identifier(value: ConfigType) -> ConfigType ) -def validate_sensor_entity_category(config: ConfigType) -> ConfigType: +def validate_sensor_entity_category( + domain: str, discovery: bool +) -> Callable[[ConfigType], ConfigType]: """Check the sensor's entity category is not set to `config` which is invalid for sensors.""" - if ( - CONF_ENTITY_CATEGORY in config - and config[CONF_ENTITY_CATEGORY] == EntityCategory.CONFIG - ): - raise vol.Invalid("Entity category `config` is invalid") - return config + + # A guard was added to the core sensor platform with HA core 2023.11.0 + # See: https://github.com/home-assistant/core/pull/101471 + # A developers blog from october 2021 explains the correct uses of the entity category + # See: + # https://developers.home-assistant.io/blog/2021/10/26/config-entity/?_highlight=entity_category#entity-categories + # + # To limitate the impact of the change we use a grace period + # of 3 months for user to update there configs. + + def _validate(config: ConfigType) -> ConfigType: + if ( + CONF_ENTITY_CATEGORY in config + and config[CONF_ENTITY_CATEGORY] == EntityCategory.CONFIG + ): + config_str: str + if not discovery: + config_str = yaml_dump(config) + config.pop(CONF_ENTITY_CATEGORY) + _LOGGER.warning( + "Entity category `config` is invalid for sensors, ignoring. " + "This stops working from HA Core 2024.2.0" + ) + # We only open an issue if the user can fix it + if discovery: + return config + config_file = getattr(config, "__config_file__", "?") + line = getattr(config, "__line__", "?") + hass = async_get_hass() + async_create_issue( + hass, + domain=DOMAIN, + issue_id="invalid_entity_category", + breaks_in_ha_version="2024.2.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="invalid_entity_category", + learn_more_url=( + f"https://www.home-assistant.io/integrations/{domain}.mqtt/" + ), + translation_placeholders={ + "domain": domain, + "config": config_str, + "config_file": config_file, + "line": line, + }, + ) + return config + + return _validate MQTT_ENTITY_DEVICE_INFO_SCHEMA = vol.All( @@ -411,8 +457,8 @@ async def async_setup_entity_entry_helper( if TYPE_CHECKING: assert entity_class is not None entities.append(entity_class(hass, config, entry, None)) - except vol.Invalid as ex: - error = str(ex) + except vol.Invalid as exc: + error = str(exc) config_file = getattr(yaml_config, "__config_file__", "?") line = getattr(yaml_config, "__line__", "?") issue_id = hex(hash(frozenset(yaml_config))) diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 2da2527ad7b..63b8d537170 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -247,15 +247,15 @@ class MqttValueTemplate: payload, variables=values ) ) - except Exception as ex: + except Exception as exc: _LOGGER.error( "%s: %s rendering template for entity '%s', template: '%s'", - type(ex).__name__, - ex, + type(exc).__name__, + exc, self._entity.entity_id if self._entity else "n/a", self._value_template.template, ) - raise ex + raise exc return rendered_payload _LOGGER.debug( diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index e1c7ba64aba..2c173f801fa 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -88,7 +88,7 @@ PLATFORM_SCHEMA_MODERN = vol.All( # Deprecated in HA Core 2021.11.0 https://github.com/home-assistant/core/pull/54840 # Removed in HA Core 2023.6.0 cv.removed(CONF_LAST_RESET_TOPIC), - validate_sensor_entity_category, + validate_sensor_entity_category(sensor.DOMAIN, discovery=False), _PLATFORM_SCHEMA_BASE, ) @@ -96,7 +96,7 @@ DISCOVERY_SCHEMA = vol.All( # Deprecated in HA Core 2021.11.0 https://github.com/home-assistant/core/pull/54840 # Removed in HA Core 2023.6.0 cv.removed(CONF_LAST_RESET_TOPIC), - validate_sensor_entity_category, + validate_sensor_entity_category(sensor.DOMAIN, discovery=True), _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), ) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 6197e580b1d..f35cd7c2b58 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -20,6 +20,10 @@ "title": "MQTT entities with auxiliary heat support found", "description": "Entity `{entity_id}` has auxiliary heat support enabled, which has been deprecated for MQTT climate devices. Please adjust your configuration and remove deprecated config options from your configuration and restart Home Assistant to fix this issue." }, + "invalid_entity_category": { + "title": "An MQTT {domain} with an invalid entity category was found", + "description": "Home Assistant detected a manually configured MQTT `{domain}` entity that has an `entity_category` set to `config`. \nConfiguration file: **{config_file}**\nNear line: **{line}**\n\nConfig with invalid setting:\n\n```yaml\n{config}\n```\n\nWhen set, make sure `entity_category` for a `{domain}` is set to `diagnostic` or `None`. Update your YAML configuration and restart Home Assistant to fix this issue." + }, "invalid_platform_config": { "title": "Invalid config found for mqtt {domain} item", "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." @@ -207,5 +211,16 @@ "name": "[%key:common::action::reload%]", "description": "Reloads MQTT entities from the YAML-configuration." } + }, + "exceptions": { + "invalid_platform_config": { + "message": "Reloading YAML config for manually configured MQTT `{domain}` item failed. See logs for more details." + }, + "mqtt_not_setup_cannot_subscribe": { + "message": "Cannot subscribe to topic '{topic}', make sure MQTT is set up correctly." + }, + "mqtt_not_setup_cannot_publish": { + "message": "Cannot publish to topic '{topic}', make sure MQTT is set up correctly." + } } } diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index 6e364182cb0..f478ad712d7 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -63,9 +63,8 @@ async def async_wait_for_mqtt_client(hass: HomeAssistant) -> bool: state_reached_future: asyncio.Future[bool] if DATA_MQTT_AVAILABLE not in hass.data: - hass.data[ - DATA_MQTT_AVAILABLE - ] = state_reached_future = hass.loop.create_future() + state_reached_future = hass.loop.create_future() + hass.data[DATA_MQTT_AVAILABLE] = state_reached_future else: state_reached_future = hass.data[DATA_MQTT_AVAILABLE] if state_reached_future.done(): diff --git a/homeassistant/components/mutesync/strings.json b/homeassistant/components/mutesync/strings.json index 2a3cca666ee..b0826384899 100644 --- a/homeassistant/components/mutesync/strings.json +++ b/homeassistant/components/mutesync/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your mutesync device." } } }, diff --git a/homeassistant/components/myq/__init__.py b/homeassistant/components/myq/__init__.py index c50ea579a14..86a158c09fa 100644 --- a/homeassistant/components/myq/__init__.py +++ b/homeassistant/components/myq/__init__.py @@ -1,122 +1,38 @@ """The MyQ integration.""" from __future__ import annotations -from datetime import timedelta -import logging - -import pymyq -from pymyq.const import ( - DEVICE_STATE as MYQ_DEVICE_STATE, - DEVICE_STATE_ONLINE as MYQ_DEVICE_STATE_ONLINE, - KNOWN_MODELS, - MANUFACTURER, -) -from pymyq.device import MyQDevice -from pymyq.errors import InvalidCredentialsError, MyQError - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers import issue_registry as ir -from .const import DOMAIN, MYQ_COORDINATOR, MYQ_GATEWAY, PLATFORMS, UPDATE_INTERVAL - -_LOGGER = logging.getLogger(__name__) +DOMAIN = "myq" -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: """Set up MyQ from a config entry.""" - - hass.data.setdefault(DOMAIN, {}) - websession = aiohttp_client.async_get_clientsession(hass) - conf = entry.data - - try: - myq = await pymyq.login(conf[CONF_USERNAME], conf[CONF_PASSWORD], websession) - except InvalidCredentialsError as err: - raise ConfigEntryAuthFailed from err - except MyQError as err: - raise ConfigEntryNotReady from err - - # Called by DataUpdateCoordinator, allows to capture any MyQError exceptions and to throw an HASS UpdateFailed - # exception instead, preventing traceback in HASS logs. - async def async_update_data(): - try: - return await myq.update_device_info() - except InvalidCredentialsError as err: - raise ConfigEntryAuthFailed from err - except MyQError as err: - raise UpdateFailed(str(err)) from err - - coordinator = DataUpdateCoordinator( + ir.async_create_issue( hass, - _LOGGER, - name="myq devices", - update_method=async_update_data, - update_interval=timedelta(seconds=UPDATE_INTERVAL), + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "blog": "https://www.home-assistant.io/blog/2023/11/06/removal-of-myq-integration/", + "entries": "/config/integrations/integration/myQ", + }, ) - hass.data[DOMAIN][entry.entry_id] = {MYQ_GATEWAY: myq, MYQ_COORDINATOR: coordinator} - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) + if all( + config_entry.state is ConfigEntryState.NOT_LOADED + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.entry_id != entry.entry_id + ): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) - return unload_ok - - -class MyQEntity(CoordinatorEntity): - """Base class for MyQ Entities.""" - - def __init__(self, coordinator: DataUpdateCoordinator, device: MyQDevice) -> None: - """Initialize class.""" - super().__init__(coordinator) - self._device = device - self._attr_unique_id = device.device_id - - @property - def name(self): - """Return the name if any, name can change if user changes it within MyQ.""" - return self._device.name - - @property - def device_info(self): - """Return the device_info of the device.""" - model = ( - KNOWN_MODELS.get(self._device.device_id[2:4]) - if self._device.device_id is not None - else None - ) - via_device: tuple[str, str] | None = None - if self._device.parent_device_id: - via_device = (DOMAIN, self._device.parent_device_id) - return DeviceInfo( - identifiers={(DOMAIN, self._device.device_id)}, - manufacturer=MANUFACTURER, - model=model, - name=self._device.name, - sw_version=self._device.firmware_version, - via_device=via_device, - ) - - @property - def available(self): - """Return if the device is online.""" - # Not all devices report online so assume True if its missing - return super().available and self._device.device_json[MYQ_DEVICE_STATE].get( - MYQ_DEVICE_STATE_ONLINE, True - ) + return True diff --git a/homeassistant/components/myq/binary_sensor.py b/homeassistant/components/myq/binary_sensor.py deleted file mode 100644 index f4c976a5879..00000000000 --- a/homeassistant/components/myq/binary_sensor.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Support for MyQ gateways.""" -from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, - BinarySensorEntity, -) -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 MyQEntity -from .const import DOMAIN, MYQ_COORDINATOR, MYQ_GATEWAY - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up mysq covers.""" - data = hass.data[DOMAIN][config_entry.entry_id] - myq = data[MYQ_GATEWAY] - coordinator = data[MYQ_COORDINATOR] - - entities = [] - - for device in myq.gateways.values(): - entities.append(MyQBinarySensorEntity(coordinator, device)) - - async_add_entities(entities) - - -class MyQBinarySensorEntity(MyQEntity, BinarySensorEntity): - """Representation of a MyQ gateway.""" - - _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY - _attr_entity_category = EntityCategory.DIAGNOSTIC - - @property - def name(self): - """Return the name of the garage door if any.""" - return f"{self._device.name} MyQ Gateway" - - @property - def is_on(self): - """Return if the device is online.""" - return super().available - - @property - def available(self) -> bool: - """Entity is always available.""" - return True diff --git a/homeassistant/components/myq/config_flow.py b/homeassistant/components/myq/config_flow.py index 930d0014d1f..27bb1c4b9e5 100644 --- a/homeassistant/components/myq/config_flow.py +++ b/homeassistant/components/myq/config_flow.py @@ -1,101 +1,11 @@ """Config flow for MyQ integration.""" -from collections.abc import Mapping -import logging -from typing import Any - -import pymyq -from pymyq.errors import InvalidCredentialsError, MyQError -import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import aiohttp_client -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -DATA_SCHEMA = vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} -) +from . import DOMAIN class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for MyQ.""" VERSION = 1 - - def __init__(self) -> None: - """Start a myq config flow.""" - self._reauth_unique_id = None - - async def _async_validate_input(self, username, password): - """Validate the user input allows us to connect.""" - websession = aiohttp_client.async_get_clientsession(self.hass) - try: - await pymyq.login(username, password, websession, True) - except InvalidCredentialsError: - return {CONF_PASSWORD: "invalid_auth"} - except MyQError: - return {"base": "cannot_connect"} - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - return {"base": "unknown"} - - return None - - async def async_step_user(self, user_input=None): - """Handle the initial step.""" - errors = {} - if user_input is not None: - errors = await self._async_validate_input( - user_input[CONF_USERNAME], user_input[CONF_PASSWORD] - ) - if not errors: - await self.async_set_unique_id(user_input[CONF_USERNAME]) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=user_input[CONF_USERNAME], data=user_input - ) - - return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors - ) - - async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: - """Handle reauth.""" - self._reauth_unique_id = self.context["unique_id"] - return await self.async_step_reauth_confirm() - - async def async_step_reauth_confirm(self, user_input=None): - """Handle reauth input.""" - errors = {} - existing_entry = await self.async_set_unique_id(self._reauth_unique_id) - if user_input is not None: - errors = await self._async_validate_input( - existing_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD] - ) - if not errors: - self.hass.config_entries.async_update_entry( - existing_entry, - data={ - **existing_entry.data, - CONF_PASSWORD: user_input[CONF_PASSWORD], - }, - ) - await self.hass.config_entries.async_reload(existing_entry.entry_id) - return self.async_abort(reason="reauth_successful") - - return self.async_show_form( - description_placeholders={ - CONF_USERNAME: existing_entry.data[CONF_USERNAME] - }, - step_id="reauth_confirm", - data_schema=vol.Schema( - { - vol.Required(CONF_PASSWORD): str, - } - ), - errors=errors, - ) diff --git a/homeassistant/components/myq/const.py b/homeassistant/components/myq/const.py deleted file mode 100644 index 16dead34477..00000000000 --- a/homeassistant/components/myq/const.py +++ /dev/null @@ -1,36 +0,0 @@ -"""The MyQ integration.""" -from pymyq.garagedoor import ( - STATE_CLOSED as MYQ_COVER_STATE_CLOSED, - STATE_CLOSING as MYQ_COVER_STATE_CLOSING, - STATE_OPEN as MYQ_COVER_STATE_OPEN, - STATE_OPENING as MYQ_COVER_STATE_OPENING, -) -from pymyq.lamp import STATE_OFF as MYQ_LIGHT_STATE_OFF, STATE_ON as MYQ_LIGHT_STATE_ON - -from homeassistant.const import ( - STATE_CLOSED, - STATE_CLOSING, - STATE_OFF, - STATE_ON, - STATE_OPEN, - STATE_OPENING, - Platform, -) - -DOMAIN = "myq" - -PLATFORMS = [Platform.COVER, Platform.BINARY_SENSOR, Platform.LIGHT] - -MYQ_TO_HASS = { - MYQ_COVER_STATE_CLOSED: STATE_CLOSED, - MYQ_COVER_STATE_CLOSING: STATE_CLOSING, - MYQ_COVER_STATE_OPEN: STATE_OPEN, - MYQ_COVER_STATE_OPENING: STATE_OPENING, - MYQ_LIGHT_STATE_ON: STATE_ON, - MYQ_LIGHT_STATE_OFF: STATE_OFF, -} - -MYQ_GATEWAY = "myq_gateway" -MYQ_COORDINATOR = "coordinator" - -UPDATE_INTERVAL = 30 diff --git a/homeassistant/components/myq/cover.py b/homeassistant/components/myq/cover.py deleted file mode 100644 index 51d0b3290a6..00000000000 --- a/homeassistant/components/myq/cover.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Support for MyQ-Enabled Garage Doors.""" -from typing import Any - -from pymyq.const import DEVICE_TYPE_GATE as MYQ_DEVICE_TYPE_GATE -from pymyq.errors import MyQError - -from homeassistant.components.cover import ( - CoverDeviceClass, - CoverEntity, - CoverEntityFeature, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from . import MyQEntity -from .const import DOMAIN, MYQ_COORDINATOR, MYQ_GATEWAY, MYQ_TO_HASS - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up mysq covers.""" - data = hass.data[DOMAIN][config_entry.entry_id] - myq = data[MYQ_GATEWAY] - coordinator = data[MYQ_COORDINATOR] - - async_add_entities( - [MyQCover(coordinator, device) for device in myq.covers.values()] - ) - - -class MyQCover(MyQEntity, CoverEntity): - """Representation of a MyQ cover.""" - - _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - - def __init__(self, coordinator, device): - """Initialize with API object, device id.""" - super().__init__(coordinator, device) - self._device = device - if device.device_type == MYQ_DEVICE_TYPE_GATE: - self._attr_device_class = CoverDeviceClass.GATE - else: - self._attr_device_class = CoverDeviceClass.GARAGE - self._attr_unique_id = device.device_id - - @property - def is_closed(self) -> bool: - """Return true if cover is closed, else False.""" - return MYQ_TO_HASS.get(self._device.state) == STATE_CLOSED - - @property - def is_closing(self) -> bool: - """Return if the cover is closing or not.""" - return MYQ_TO_HASS.get(self._device.state) == STATE_CLOSING - - @property - def is_open(self) -> bool: - """Return if the cover is opening or not.""" - return MYQ_TO_HASS.get(self._device.state) == STATE_OPEN - - @property - def is_opening(self) -> bool: - """Return if the cover is opening or not.""" - return MYQ_TO_HASS.get(self._device.state) == STATE_OPENING - - async def async_close_cover(self, **kwargs: Any) -> None: - """Issue close command to cover.""" - if self.is_closing or self.is_closed: - return - - try: - wait_task = await self._device.close(wait_for_state=False) - except MyQError as err: - raise HomeAssistantError( - f"Closing of cover {self._device.name} failed with error: {err}" - ) from err - - # Write closing state to HASS - self.async_write_ha_state() - - result = wait_task if isinstance(wait_task, bool) else await wait_task - - # Write final state to HASS - self.async_write_ha_state() - - if not result: - raise HomeAssistantError(f"Closing of cover {self._device.name} failed") - - async def async_open_cover(self, **kwargs: Any) -> None: - """Issue open command to cover.""" - if self.is_opening or self.is_open: - return - - try: - wait_task = await self._device.open(wait_for_state=False) - except MyQError as err: - raise HomeAssistantError( - f"Opening of cover {self._device.name} failed with error: {err}" - ) from err - - # Write opening state to HASS - self.async_write_ha_state() - - result = wait_task if isinstance(wait_task, bool) else await wait_task - - # Write final state to HASS - self.async_write_ha_state() - - if not result: - raise HomeAssistantError(f"Opening of cover {self._device.name} failed") diff --git a/homeassistant/components/myq/light.py b/homeassistant/components/myq/light.py deleted file mode 100644 index 684af64a82e..00000000000 --- a/homeassistant/components/myq/light.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Support for MyQ-Enabled lights.""" -from typing import Any - -from pymyq.errors import MyQError - -from homeassistant.components.light import ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from . import MyQEntity -from .const import DOMAIN, MYQ_COORDINATOR, MYQ_GATEWAY, MYQ_TO_HASS - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up myq lights.""" - data = hass.data[DOMAIN][config_entry.entry_id] - myq = data[MYQ_GATEWAY] - coordinator = data[MYQ_COORDINATOR] - - async_add_entities( - [MyQLight(coordinator, device) for device in myq.lamps.values()], True - ) - - -class MyQLight(MyQEntity, LightEntity): - """Representation of a MyQ light.""" - - _attr_color_mode = ColorMode.ONOFF - _attr_supported_color_modes = {ColorMode.ONOFF} - - @property - def is_on(self): - """Return true if the light is on, else False.""" - return MYQ_TO_HASS.get(self._device.state) == STATE_ON - - @property - def is_off(self): - """Return true if the light is off, else False.""" - return MYQ_TO_HASS.get(self._device.state) == STATE_OFF - - async def async_turn_on(self, **kwargs: Any) -> None: - """Issue on command to light.""" - if self.is_on: - return - - try: - await self._device.turnon(wait_for_state=True) - except MyQError as err: - raise HomeAssistantError( - f"Turning light {self._device.name} on failed with error: {err}" - ) from err - - # Write new state to HASS - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Issue off command to light.""" - if self.is_off: - return - - try: - await self._device.turnoff(wait_for_state=True) - except MyQError as err: - raise HomeAssistantError( - f"Turning light {self._device.name} off failed with error: {err}" - ) from err - - # Write new state to HASS - self.async_write_ha_state() diff --git a/homeassistant/components/myq/manifest.json b/homeassistant/components/myq/manifest.json index e924d06955b..dd265c4a428 100644 --- a/homeassistant/components/myq/manifest.json +++ b/homeassistant/components/myq/manifest.json @@ -1,18 +1,9 @@ { "domain": "myq", "name": "MyQ", - "codeowners": ["@ehendrix23", "@Lash-L"], - "config_flow": true, - "dhcp": [ - { - "macaddress": "645299*" - } - ], + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/myq", - "homekit": { - "models": ["819LMB", "MYQ"] - }, + "integration_type": "system", "iot_class": "cloud_polling", - "loggers": ["pkce", "pymyq"], - "requirements": ["python-myq==3.1.13"] + "requirements": [] } diff --git a/homeassistant/components/myq/strings.json b/homeassistant/components/myq/strings.json index c986b8a8997..85359302c99 100644 --- a/homeassistant/components/myq/strings.json +++ b/homeassistant/components/myq/strings.json @@ -1,29 +1,8 @@ { - "config": { - "step": { - "user": { - "title": "Connect to the MyQ Gateway", - "data": { - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" - } - }, - "reauth_confirm": { - "description": "The password for {username} is no longer valid.", - "title": "Reauthenticate your MyQ Account", - "data": { - "password": "[%key:common::config_flow::data::password%]" - } - } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "issues": { + "integration_removed": { + "title": "The MyQ integration has been removed", + "description": "The MyQ integration has been removed from Home Assistant.\n\nMyQ has blocked all third-party integrations. Read about it [here]({blog}).\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing MyQ integration entries]({entries})." } } } diff --git a/homeassistant/components/mystrom/config_flow.py b/homeassistant/components/mystrom/config_flow.py index 3dc334d8252..6b2fe85bfe8 100644 --- a/homeassistant/components/mystrom/config_flow.py +++ b/homeassistant/components/mystrom/config_flow.py @@ -31,10 +31,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Handle import from config.""" - return await self.async_step_user(import_config) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/mystrom/light.py b/homeassistant/components/mystrom/light.py index 6a6e7efa1b3..ce9357d23f7 100644 --- a/homeassistant/components/mystrom/light.py +++ b/homeassistant/components/mystrom/light.py @@ -5,25 +5,19 @@ import logging from typing import Any from pymystrom.exceptions import MyStromConnectionError -import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_EFFECT, ATTR_HS_COLOR, - PLATFORM_SCHEMA, ColorMode, LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.config_validation as cv +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.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN, MANUFACTURER @@ -34,14 +28,6 @@ DEFAULT_NAME = "myStrom bulb" EFFECT_RAINBOW = "rainbow" EFFECT_SUNRISE = "sunrise" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_MAC): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -52,34 +38,6 @@ async def async_setup_entry( async_add_entities([MyStromLight(device, entry.title, info["mac"])]) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the myStrom light integration.""" - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2023.12.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "myStrom", - }, - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - - class MyStromLight(LightEntity): """Representation of the myStrom WiFi bulb.""" diff --git a/homeassistant/components/mystrom/strings.json b/homeassistant/components/mystrom/strings.json index a485a58f5a6..9ebd1c36df0 100644 --- a/homeassistant/components/mystrom/strings.json +++ b/homeassistant/components/mystrom/strings.json @@ -5,6 +5,9 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your myStrom device." } } }, diff --git a/homeassistant/components/mystrom/switch.py b/homeassistant/components/mystrom/switch.py index 262ee54101b..8f459e6801e 100644 --- a/homeassistant/components/mystrom/switch.py +++ b/homeassistant/components/mystrom/switch.py @@ -5,17 +5,12 @@ import logging from typing import Any from pymystrom.exceptions import MyStromConnectionError -import voluptuous as vol -from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.components.switch import SwitchEntity +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.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN, MANUFACTURER @@ -23,13 +18,6 @@ DEFAULT_NAME = "myStrom Switch" _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -39,34 +27,6 @@ async def async_setup_entry( async_add_entities([MyStromSwitch(device, entry.title)]) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the myStrom switch/plug integration.""" - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2023.12.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "myStrom", - }, - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - - class MyStromSwitch(SwitchEntity): """Representation of a myStrom switch/plug.""" diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index be571460b4a..a4ef9af9aee 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["nettigo_air_monitor"], "quality_scale": "platinum", - "requirements": ["nettigo-air-monitor==2.2.0"], + "requirements": ["nettigo-air-monitor==2.2.2"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json index e443a398984..83a40d87f76 100644 --- a/homeassistant/components/nam/strings.json +++ b/homeassistant/components/nam/strings.json @@ -6,6 +6,9 @@ "description": "Set up Nettigo Air Monitor integration.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Nettigo Air Monitor to control." } }, "credentials": { diff --git a/homeassistant/components/nanoleaf/strings.json b/homeassistant/components/nanoleaf/strings.json index 80eb2ded7d0..13e7c9a11a3 100644 --- a/homeassistant/components/nanoleaf/strings.json +++ b/homeassistant/components/nanoleaf/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Nanoleaf device." } }, "link": { diff --git a/homeassistant/components/neato/strings.json b/homeassistant/components/neato/strings.json index d611abb83b0..6a442e7c353 100644 --- a/homeassistant/components/neato/strings.json +++ b/homeassistant/components/neato/strings.json @@ -13,7 +13,11 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 717ce5075f7..35e1cc68165 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -49,7 +49,11 @@ "unknown_authorize_url_generation": "[%key:common::config_flow::abort::unknown_authorize_url_generation%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]" + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index f4715015844..5a05818d3f2 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -5,6 +5,7 @@ import logging from typing import Any, cast from pyatmo.modules import NATherm1 +from pyatmo.modules.device_types import DeviceType import voluptuous as vol from homeassistant.components.climate import ( @@ -38,6 +39,8 @@ from .const import ( ATTR_HEATING_POWER_REQUEST, ATTR_SCHEDULE_NAME, ATTR_SELECTED_SCHEDULE, + ATTR_TARGET_TEMPERATURE, + ATTR_TIME_PERIOD, CONF_URL_ENERGY, DATA_SCHEDULES, DOMAIN, @@ -46,8 +49,11 @@ from .const import ( EVENT_TYPE_SET_POINT, EVENT_TYPE_THERM_MODE, NETATMO_CREATE_CLIMATE, + SERVICE_CLEAR_TEMPERATURE_SETTING, SERVICE_SET_PRESET_MODE_WITH_END_DATETIME, SERVICE_SET_SCHEDULE, + SERVICE_SET_TEMPERATURE_WITH_END_DATETIME, + SERVICE_SET_TEMPERATURE_WITH_TIME_PERIOD, ) from .data_handler import HOME, SIGNAL_NAME, NetatmoRoom from .netatmo_entity_base import NetatmoBase @@ -106,8 +112,8 @@ CURRENT_HVAC_MAP_NETATMO = {True: HVACAction.HEATING, False: HVACAction.IDLE} DEFAULT_MAX_TEMP = 30 -NA_THERM = "NATherm1" -NA_VALVE = "NRV" +NA_THERM = DeviceType.NATherm1 +NA_VALVE = DeviceType.NRV async def async_setup_entry( @@ -117,6 +123,10 @@ async def async_setup_entry( @callback def _create_entity(netatmo_device: NetatmoRoom) -> None: + if not netatmo_device.room.climate_type: + msg = f"No climate type found for this room: {netatmo_device.room.name}" + _LOGGER.debug(msg) + return entity = NetatmoThermostat(netatmo_device) async_add_entities([entity]) @@ -138,6 +148,34 @@ async def async_setup_entry( }, "_async_service_set_preset_mode_with_end_datetime", ) + platform.async_register_entity_service( + SERVICE_SET_TEMPERATURE_WITH_END_DATETIME, + { + vol.Required(ATTR_TARGET_TEMPERATURE): vol.All( + vol.Coerce(float), vol.Range(min=7, max=30) + ), + vol.Required(ATTR_END_DATETIME): cv.datetime, + }, + "_async_service_set_temperature_with_end_datetime", + ) + platform.async_register_entity_service( + SERVICE_SET_TEMPERATURE_WITH_TIME_PERIOD, + { + vol.Required(ATTR_TARGET_TEMPERATURE): vol.All( + vol.Coerce(float), vol.Range(min=7, max=30) + ), + vol.Required(ATTR_TIME_PERIOD): vol.All( + cv.time_period, + cv.positive_timedelta, + ), + }, + "_async_service_set_temperature_with_time_period", + ) + platform.async_register_entity_service( + SERVICE_CLEAR_TEMPERATURE_SETTING, + {}, + "_async_service_clear_temperature_setting", + ) class NetatmoThermostat(NetatmoBase, ClimateEntity): @@ -170,7 +208,8 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): ] ) - self._model: str = f"{self._room.climate_type}" + assert self._room.climate_type + self._model: DeviceType = self._room.climate_type self._config_url = CONF_URL_ENERGY @@ -184,7 +223,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): self._selected_schedule = None self._attr_hvac_modes = [HVACMode.AUTO, HVACMode.HEAT] - if self._model == NA_THERM: + if self._model is NA_THERM: self._attr_hvac_modes.append(HVACMode.OFF) self._attr_unique_id = f"{self._room.entity_id}-{self._model}" @@ -435,12 +474,48 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): mode=PRESET_MAP_NETATMO[preset_mode], end_time=end_timestamp ) _LOGGER.debug( - "Setting %s preset to %s with optional end datetime to %s", + "Setting %s preset to %s with end datetime %s", self._room.home.entity_id, preset_mode, end_timestamp, ) + async def _async_service_set_temperature_with_end_datetime( + self, **kwargs: Any + ) -> None: + target_temperature = kwargs[ATTR_TARGET_TEMPERATURE] + end_datetime = kwargs[ATTR_END_DATETIME] + end_timestamp = int(dt_util.as_timestamp(end_datetime)) + + _LOGGER.debug( + "Setting %s to target temperature %s with end datetime %s", + self._room.entity_id, + target_temperature, + end_timestamp, + ) + await self._room.async_therm_manual(target_temperature, end_timestamp) + + async def _async_service_set_temperature_with_time_period( + self, **kwargs: Any + ) -> None: + target_temperature = kwargs[ATTR_TARGET_TEMPERATURE] + time_period = kwargs[ATTR_TIME_PERIOD] + + _LOGGER.debug( + "Setting %s to target temperature %s with time period %s", + self._room.entity_id, + target_temperature, + time_period, + ) + + now_timestamp = dt_util.as_timestamp(dt_util.utcnow()) + end_timestamp = int(now_timestamp + time_period.seconds) + await self._room.async_therm_manual(target_temperature, end_timestamp) + + async def _async_service_clear_temperature_setting(self, **kwargs: Any) -> None: + _LOGGER.debug("Clearing %s temperature setting", self._room.entity_id) + await self._room.async_therm_home() + @property def device_info(self) -> DeviceInfo: """Return the device info for the thermostat.""" diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 8a281d4d4a2..3fe456dd657 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -89,12 +89,17 @@ ATTR_PSEUDO = "pseudo" ATTR_SCHEDULE_ID = "schedule_id" ATTR_SCHEDULE_NAME = "schedule_name" ATTR_SELECTED_SCHEDULE = "selected_schedule" +ATTR_TARGET_TEMPERATURE = "target_temperature" +ATTR_TIME_PERIOD = "time_period" +SERVICE_CLEAR_TEMPERATURE_SETTING = "clear_temperature_setting" SERVICE_SET_CAMERA_LIGHT = "set_camera_light" SERVICE_SET_PERSON_AWAY = "set_person_away" SERVICE_SET_PERSONS_HOME = "set_persons_home" SERVICE_SET_SCHEDULE = "set_schedule" SERVICE_SET_PRESET_MODE_WITH_END_DATETIME = "set_preset_mode_with_end_datetime" +SERVICE_SET_TEMPERATURE_WITH_END_DATETIME = "set_temperature_with_end_datetime" +SERVICE_SET_TEMPERATURE_WITH_TIME_PERIOD = "set_temperature_with_time_period" # Climate events EVENT_TYPE_CANCEL_SET_POINT = "cancel_set_point" diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index 8fa8ab2073d..e1d100f773e 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -12,7 +12,10 @@ from typing import Any import aiohttp import pyatmo -from pyatmo.modules.device_types import DeviceCategory as NetatmoDeviceCategory +from pyatmo.modules.device_types import ( + DeviceCategory as NetatmoDeviceCategory, + DeviceType as NetatmoDeviceType, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback @@ -53,7 +56,7 @@ ACCOUNT = "account" HOME = "home" WEATHER = "weather" AIR_CARE = "air_care" -PUBLIC = "public" +PUBLIC = NetatmoDeviceType.public EVENT = "event" PUBLISHERS = { diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index d031632ed75..7d84641874a 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -7,7 +7,7 @@ "dependencies": ["application_credentials", "webhook"], "documentation": "https://www.home-assistant.io/integrations/netatmo", "homekit": { - "models": ["Healty Home Coach", "Netatmo Relay", "Presence", "Welcome"] + "models": ["Healthy Home Coach", "Netatmo Relay", "Presence", "Welcome"] }, "integration_type": "hub", "iot_class": "cloud_polling", diff --git a/homeassistant/components/netatmo/netatmo_entity_base.py b/homeassistant/components/netatmo/netatmo_entity_base.py index 4cf5766b6b5..54915facb3a 100644 --- a/homeassistant/components/netatmo/netatmo_entity_base.py +++ b/homeassistant/components/netatmo/netatmo_entity_base.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from pyatmo import DeviceType from pyatmo.modules.device_types import ( DEVICE_DESCRIPTION_MAP, DeviceType as NetatmoDeviceType, @@ -29,7 +30,7 @@ class NetatmoBase(Entity): self._device_name: str = "" self._id: str = "" - self._model: str = "" + self._model: DeviceType self._config_url: str | None = None self._attr_name = None self._attr_unique_id = None diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py index 3651ae05e88..b02c63698f3 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -3,6 +3,8 @@ from __future__ import annotations import logging +from pyatmo import DeviceType + from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -65,7 +67,7 @@ class NetatmoScheduleSelect(NetatmoBase, SelectEntity): self._device_name = self._home.name self._attr_name = f"{self._device_name}" - self._model: str = "NATherm1" + self._model = DeviceType.NATherm1 self._config_url = CONF_URL_ENERGY self._attr_unique_id = f"{self._home_id}-schedule-select" diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index f286e53772c..10114a75f63 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -322,6 +322,10 @@ async def async_setup_entry( @callback def _create_room_sensor_entity(netatmo_device: NetatmoRoom) -> None: + if not netatmo_device.room.climate_type: + msg = f"No climate type found for this room: {netatmo_device.room.name}" + _LOGGER.debug(msg) + return async_add_entities( NetatmoRoomSensor(netatmo_device, description) for description in SENSOR_TYPES @@ -633,9 +637,11 @@ class NetatmoRoomSensor(NetatmoBase, SensorEntity): self._attr_name = f"{self._room.name} {self.entity_description.name}" self._room_id = self._room.entity_id - self._model = f"{self._room.climate_type}" self._config_url = CONF_URL_ENERGY + assert self._room.climate_type + self._model = self._room.climate_type + self._attr_unique_id = ( f"{self._id}-{self._room.entity_id}-{self.entity_description.key}" ) diff --git a/homeassistant/components/netatmo/services.yaml b/homeassistant/components/netatmo/services.yaml index 228f84f175d..cab0528199d 100644 --- a/homeassistant/components/netatmo/services.yaml +++ b/homeassistant/components/netatmo/services.yaml @@ -46,6 +46,56 @@ set_preset_mode_with_end_datetime: selector: datetime: +set_temperature_with_end_datetime: + target: + entity: + integration: netatmo + domain: climate + fields: + target_temperature: + required: true + example: "19.5" + selector: + number: + min: 7 + max: 30 + step: 0.5 + end_datetime: + required: true + example: '"2019-04-20 05:04:20"' + selector: + datetime: + +set_temperature_with_time_period: + target: + entity: + integration: netatmo + domain: climate + fields: + target_temperature: + required: true + example: "19.5" + selector: + number: + min: 7 + max: 30 + step: 0.5 + time_period: + required: true + default: + hours: 3 + minutes: 0 + seconds: 0 + days: 0 + selector: + duration: + +clear_temperature_setting: + target: + entity: + integration: netatmo + domain: climate + set_persons_home: target: entity: diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index 593320827fd..e504b27b599 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -14,7 +14,11 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" @@ -117,7 +121,7 @@ "description": "Unregisters the webhook from the Netatmo backend." }, "set_preset_mode_with_end_datetime": { - "name": "Set preset mode with end datetime", + "name": "Set preset mode with end date & time", "description": "Sets the preset mode for a Netatmo climate device. The preset mode must match a preset mode configured at Netatmo.", "fields": { "preset_mode": { @@ -125,10 +129,42 @@ "description": "Climate preset mode such as Schedule, Away or Frost Guard." }, "end_datetime": { - "name": "End datetime", - "description": "Datetime for until when the preset will be active." + "name": "End date & time", + "description": "Date & time the preset will be active until." } } + }, + "set_temperature_with_end_datetime": { + "name": "Set temperature with end date & time", + "description": "Sets the target temperature for a Netatmo climate device with an end date & time.", + "fields": { + "target_temperature": { + "name": "Target temperature", + "description": "The target temperature for the device." + }, + "end_datetime": { + "name": "[%key:component::netatmo::services::set_preset_mode_with_end_datetime::fields::end_datetime::name%]", + "description": "Date & time the target temperature will be active until." + } + } + }, + "set_temperature_with_time_period": { + "name": "Set temperature with time period", + "description": "Sets the target temperature for a Netatmo climate device with time period.", + "fields": { + "target_temperature": { + "name": "[%key:component::netatmo::services::set_temperature_with_end_datetime::fields::target_temperature::name%]", + "description": "[%key:component::netatmo::services::set_temperature_with_end_datetime::fields::target_temperature::description%]" + }, + "time_period": { + "name": "Time period", + "description": "The time period which the temperature setting will be active for." + } + } + }, + "clear_temperature_setting": { + "name": "Clear temperature setting", + "description": "Clears any temperature setting for a Netatmo climate device reverting it to the current preset or schedule." } } } diff --git a/homeassistant/components/netgear/strings.json b/homeassistant/components/netgear/strings.json index 6b4883b8ce3..9f3b1aeec9e 100644 --- a/homeassistant/components/netgear/strings.json +++ b/homeassistant/components/netgear/strings.json @@ -7,6 +7,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Netgear device. For example: '192.168.1.1'." } } }, diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index ed9ee49a0de..d6ce3cb0994 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -124,7 +124,7 @@ class LTEData: """Shared state.""" websession = attr.ib() - modem_data = attr.ib(init=False, factory=dict) + modem_data: dict[str, ModemData] = attr.ib(init=False, factory=dict) def get_modem_data(self, config): """Get modem_data for the host in config.""" diff --git a/homeassistant/components/nextbus/config_flow.py b/homeassistant/components/nextbus/config_flow.py index 000dd86eb52..84417a29c8d 100644 --- a/homeassistant/components/nextbus/config_flow.py +++ b/homeassistant/components/nextbus/config_flow.py @@ -16,6 +16,7 @@ from homeassistant.helpers.selector import ( ) from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP, DOMAIN +from .util import listify _LOGGER = logging.getLogger(__name__) @@ -51,7 +52,7 @@ def _get_stop_tags( title_counts = Counter(tags.values()) stop_directions: dict[str, str] = {} - for direction in route_config["route"]["direction"]: + for direction in listify(route_config["route"]["direction"]): for stop in direction["stop"]: stop_directions[stop["tag"]] = direction["name"] diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 16c8adb77ce..6800c403ee8 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -33,33 +34,37 @@ UNIT_OF_LOAD: Final[str] = "load" class NextcloudSensorEntityDescription(SensorEntityDescription): """Describes Nextcloud sensor entity.""" - value_fn: Callable[ - [str | int | float], str | int | float | datetime - ] = lambda value: value + value_fn: Callable[[str | int | float], str | int | float | datetime] = ( + lambda value: value + ) SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ NextcloudSensorEntityDescription( key="activeUsers_last1hour", translation_key="nextcloud_activeusers_last1hour", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:account-multiple", ), NextcloudSensorEntityDescription( key="activeUsers_last24hours", translation_key="nextcloud_activeusers_last24hours", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:account-multiple", ), NextcloudSensorEntityDescription( key="activeUsers_last5minutes", translation_key="nextcloud_activeusers_last5minutes", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:account-multiple", ), NextcloudSensorEntityDescription( key="cache_expunges", translation_key="nextcloud_cache_expunges", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -81,30 +86,35 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ NextcloudSensorEntityDescription( key="cache_num_entries", translation_key="nextcloud_cache_num_entries", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), NextcloudSensorEntityDescription( key="cache_num_hits", translation_key="nextcloud_cache_num_hits", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), NextcloudSensorEntityDescription( key="cache_num_inserts", translation_key="nextcloud_cache_num_inserts", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), NextcloudSensorEntityDescription( key="cache_num_misses", translation_key="nextcloud_cache_num_misses", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), NextcloudSensorEntityDescription( key="cache_num_slots", translation_key="nextcloud_cache_num_slots", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -166,6 +176,7 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ NextcloudSensorEntityDescription( key="interned_strings_usage_number_of_strings", translation_key="nextcloud_interned_strings_usage_number_of_strings", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -220,6 +231,7 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ NextcloudSensorEntityDescription( key="opcache_statistics_blacklist_miss_ratio", translation_key="nextcloud_opcache_statistics_blacklist_miss_ratio", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=PERCENTAGE, @@ -227,18 +239,21 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ NextcloudSensorEntityDescription( key="opcache_statistics_blacklist_misses", translation_key="nextcloud_opcache_statistics_blacklist_misses", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), NextcloudSensorEntityDescription( key="opcache_statistics_hash_restarts", translation_key="nextcloud_opcache_statistics_hash_restarts", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), NextcloudSensorEntityDescription( key="opcache_statistics_hits", translation_key="nextcloud_opcache_statistics_hits", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -253,36 +268,42 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ NextcloudSensorEntityDescription( key="opcache_statistics_manual_restarts", translation_key="nextcloud_opcache_statistics_manual_restarts", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), NextcloudSensorEntityDescription( key="opcache_statistics_max_cached_keys", translation_key="nextcloud_opcache_statistics_max_cached_keys", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), NextcloudSensorEntityDescription( key="opcache_statistics_misses", translation_key="nextcloud_opcache_statistics_misses", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), NextcloudSensorEntityDescription( key="opcache_statistics_num_cached_keys", translation_key="nextcloud_opcache_statistics_num_cached_keys", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), NextcloudSensorEntityDescription( key="opcache_statistics_num_cached_scripts", translation_key="nextcloud_opcache_statistics_num_cached_scripts", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), NextcloudSensorEntityDescription( key="opcache_statistics_oom_restarts", translation_key="nextcloud_opcache_statistics_oom_restarts", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -386,45 +407,54 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ NextcloudSensorEntityDescription( key="shares_num_fed_shares_sent", translation_key="nextcloud_shares_num_fed_shares_sent", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), NextcloudSensorEntityDescription( key="shares_num_fed_shares_received", translation_key="nextcloud_shares_num_fed_shares_received", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), NextcloudSensorEntityDescription( key="shares_num_shares", translation_key="nextcloud_shares_num_shares", + state_class=SensorStateClass.MEASUREMENT, ), NextcloudSensorEntityDescription( key="shares_num_shares_groups", translation_key="nextcloud_shares_num_shares_groups", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), NextcloudSensorEntityDescription( key="shares_num_shares_link", translation_key="nextcloud_shares_num_shares_link", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), NextcloudSensorEntityDescription( key="shares_num_shares_link_no_password", translation_key="nextcloud_shares_num_shares_link_no_password", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), NextcloudSensorEntityDescription( key="shares_num_shares_mail", translation_key="nextcloud_shares_num_shares_mail", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), NextcloudSensorEntityDescription( key="shares_num_shares_room", translation_key="nextcloud_shares_num_shares_room", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), NextcloudSensorEntityDescription( key="shares_num_shares_user", translation_key="nextcloud_shares_num_shares_user", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), NextcloudSensorEntityDescription( @@ -440,6 +470,7 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ NextcloudSensorEntityDescription( key="sma_num_seg", translation_key="nextcloud_sma_num_seg", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -456,37 +487,45 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ NextcloudSensorEntityDescription( key="storage_num_files", translation_key="nextcloud_storage_num_files", + state_class=SensorStateClass.MEASUREMENT, ), NextcloudSensorEntityDescription( key="storage_num_storages", translation_key="nextcloud_storage_num_storages", + state_class=SensorStateClass.MEASUREMENT, ), NextcloudSensorEntityDescription( key="storage_num_storages_home", translation_key="nextcloud_storage_num_storages_home", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), NextcloudSensorEntityDescription( key="storage_num_storages_local", translation_key="nextcloud_storage_num_storages_local", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), NextcloudSensorEntityDescription( key="storage_num_storages_other", translation_key="nextcloud_storage_num_storages_other", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), NextcloudSensorEntityDescription( key="storage_num_users", translation_key="nextcloud_storage_num_users", + state_class=SensorStateClass.MEASUREMENT, ), NextcloudSensorEntityDescription( key="system_apps_num_installed", translation_key="nextcloud_system_apps_num_installed", + state_class=SensorStateClass.MEASUREMENT, ), NextcloudSensorEntityDescription( key="system_apps_num_updates_available", translation_key="nextcloud_system_apps_num_updates_available", + state_class=SensorStateClass.MEASUREMENT, icon="mdi:update", ), NextcloudSensorEntityDescription( diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json index ddd2e400dab..611021d73e4 100644 --- a/homeassistant/components/nextdns/manifest.json +++ b/homeassistant/components/nextdns/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["nextdns"], "quality_scale": "platinum", - "requirements": ["nextdns==2.0.0"] + "requirements": ["nextdns==2.1.0"] } diff --git a/homeassistant/components/nfandroidtv/strings.json b/homeassistant/components/nfandroidtv/strings.json index fdc9f01d343..cde02327712 100644 --- a/homeassistant/components/nfandroidtv/strings.json +++ b/homeassistant/components/nfandroidtv/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "The hostname or IP address of your TV." } } }, diff --git a/homeassistant/components/nibe_heatpump/__init__.py b/homeassistant/components/nibe_heatpump/__init__.py index 01a51f015d9..058f3ef8711 100644 --- a/homeassistant/components/nibe_heatpump/__init__.py +++ b/homeassistant/components/nibe_heatpump/__init__.py @@ -1,20 +1,11 @@ """The Nibe Heat Pump integration.""" from __future__ import annotations -import asyncio -from collections import defaultdict -from collections.abc import Callable, Iterable -from datetime import timedelta -from typing import Any, Generic, TypeVar - -from nibe.coil import Coil, CoilData from nibe.connection import Connection from nibe.connection.modbus import Modbus from nibe.connection.nibegw import NibeGW, ProductInfo -from nibe.exceptions import CoilNotFoundException, ReadException -from nibe.heatpump import HeatPump, Model, Series +from nibe.heatpump import HeatPump, Model -from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_IP_ADDRESS, @@ -22,16 +13,9 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) from .const import ( CONF_CONNECTION_TYPE, @@ -44,8 +28,8 @@ from .const import ( CONF_REMOTE_WRITE_PORT, CONF_WORD_SWAP, DOMAIN, - LOGGER, ) +from .coordinator import Coordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -131,218 +115,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - coordinator: Coordinator = hass.data[DOMAIN].pop(entry.entry_id) - await coordinator.async_shutdown() + hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -_DataTypeT = TypeVar("_DataTypeT") -_ContextTypeT = TypeVar("_ContextTypeT") - - -class ContextCoordinator( - Generic[_DataTypeT, _ContextTypeT], DataUpdateCoordinator[_DataTypeT] -): - """Update coordinator with context adjustments.""" - - @cached_property - def context_callbacks(self) -> dict[_ContextTypeT, list[CALLBACK_TYPE]]: - """Return a dict of all callbacks registered for a given context.""" - callbacks: dict[_ContextTypeT, list[CALLBACK_TYPE]] = defaultdict(list) - for update_callback, context in list(self._listeners.values()): - assert isinstance(context, set) - for address in context: - callbacks[address].append(update_callback) - return callbacks - - @callback - def async_update_context_listeners(self, contexts: Iterable[_ContextTypeT]) -> None: - """Update all listeners given a set of contexts.""" - update_callbacks: set[CALLBACK_TYPE] = set() - for context in contexts: - update_callbacks.update(self.context_callbacks.get(context, [])) - - for update_callback in update_callbacks: - update_callback() - - @callback - def async_add_listener( - self, update_callback: CALLBACK_TYPE, context: Any = None - ) -> Callable[[], None]: - """Wrap standard function to prune cached callback database.""" - assert isinstance(context, set) - context -= {None} - release = super().async_add_listener(update_callback, context) - self.__dict__.pop("context_callbacks", None) - - @callback - def release_update(): - release() - self.__dict__.pop("context_callbacks", None) - - return release_update - - -class Coordinator(ContextCoordinator[dict[int, CoilData], int]): - """Update coordinator for nibe heat pumps.""" - - config_entry: ConfigEntry - - def __init__( - self, - hass: HomeAssistant, - heatpump: HeatPump, - connection: Connection, - ) -> None: - """Initialize coordinator.""" - super().__init__( - hass, LOGGER, name="Nibe Heat Pump", update_interval=timedelta(seconds=60) - ) - - self.data = {} - self.seed: dict[int, CoilData] = {} - self.connection = connection - self.heatpump = heatpump - self.task: asyncio.Task | None = None - - heatpump.subscribe(heatpump.COIL_UPDATE_EVENT, self._on_coil_update) - - def _on_coil_update(self, data: CoilData): - """Handle callback on coil updates.""" - coil = data.coil - self.data[coil.address] = data - self.seed[coil.address] = data - self.async_update_context_listeners([coil.address]) - - @property - def series(self) -> Series: - """Return which series of pump we are connected to.""" - return self.heatpump.series - - @property - def coils(self) -> list[Coil]: - """Return the full coil database.""" - return self.heatpump.get_coils() - - @property - def unique_id(self) -> str: - """Return unique id for this coordinator.""" - return self.config_entry.unique_id or self.config_entry.entry_id - - @property - def device_info(self) -> DeviceInfo: - """Return device information for the main device.""" - return DeviceInfo(identifiers={(DOMAIN, self.unique_id)}) - - def get_coil_value(self, coil: Coil) -> int | str | float | None: - """Return a coil with data and check for validity.""" - if coil_with_data := self.data.get(coil.address): - return coil_with_data.value - return None - - def get_coil_float(self, coil: Coil) -> float | None: - """Return a coil with float and check for validity.""" - if value := self.get_coil_value(coil): - return float(value) - return None - - async def async_write_coil(self, coil: Coil, value: int | float | str) -> None: - """Write coil and update state.""" - data = CoilData(coil, value) - await self.connection.write_coil(data) - - self.data[coil.address] = data - - self.async_update_context_listeners([coil.address]) - - async def async_read_coil(self, coil: Coil) -> CoilData: - """Read coil and update state using callbacks.""" - return await self.connection.read_coil(coil) - - async def _async_update_data(self) -> dict[int, CoilData]: - self.task = asyncio.current_task() - try: - return await self._async_update_data_internal() - finally: - self.task = None - - async def _async_update_data_internal(self) -> dict[int, CoilData]: - result: dict[int, CoilData] = {} - - def _get_coils() -> Iterable[Coil]: - for address in sorted(self.context_callbacks.keys()): - if seed := self.seed.pop(address, None): - self.logger.debug("Skipping seeded coil: %d", address) - result[address] = seed - continue - - try: - coil = self.heatpump.get_coil_by_address(address) - except CoilNotFoundException as exception: - self.logger.debug("Skipping missing coil: %s", exception) - continue - yield coil - - try: - async for data in self.connection.read_coils(_get_coils()): - result[data.coil.address] = data - self.seed.pop(data.coil.address, None) - except ReadException as exception: - if not result: - raise UpdateFailed(f"Failed to update: {exception}") from exception - self.logger.debug( - "Some coils failed to update, and may be unsupported: %s", exception - ) - - return result - - async def async_shutdown(self): - """Make sure a coordinator is shut down as well as it's connection.""" - if self.task: - self.task.cancel() - await asyncio.wait((self.task,)) - self._unschedule_refresh() - await self.connection.stop() - - -class CoilEntity(CoordinatorEntity[Coordinator]): - """Base for coil based entities.""" - - _attr_has_entity_name = True - _attr_entity_registry_enabled_default = False - - def __init__( - self, coordinator: Coordinator, coil: Coil, entity_format: str - ) -> None: - """Initialize base entity.""" - super().__init__(coordinator, {coil.address}) - self.entity_id = async_generate_entity_id( - entity_format, coil.name, hass=coordinator.hass - ) - self._attr_name = coil.title - self._attr_unique_id = f"{coordinator.unique_id}-{coil.address}" - self._attr_device_info = coordinator.device_info - self._coil = coil - - @property - def available(self) -> bool: - """Return if entity is available.""" - return self.coordinator.last_update_success and self._coil.address in ( - self.coordinator.data or {} - ) - - def _async_read_coil(self, data: CoilData): - """Update state of entity based on coil data.""" - - async def _async_write_coil(self, value: int | float | str): - """Write coil and update state.""" - await self.coordinator.async_write_coil(self._coil, value) - - def _handle_coordinator_update(self) -> None: - data = self.coordinator.data.get(self._coil.address) - if data is None: - return - - self._async_read_coil(data) - self.async_write_ha_state() diff --git a/homeassistant/components/nibe_heatpump/binary_sensor.py b/homeassistant/components/nibe_heatpump/binary_sensor.py index 263fd41b309..d1fdfa710a1 100644 --- a/homeassistant/components/nibe_heatpump/binary_sensor.py +++ b/homeassistant/components/nibe_heatpump/binary_sensor.py @@ -9,7 +9,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, CoilEntity, Coordinator +from .const import DOMAIN +from .coordinator import CoilEntity, Coordinator async def async_setup_entry( diff --git a/homeassistant/components/nibe_heatpump/button.py b/homeassistant/components/nibe_heatpump/button.py index f552d74d281..f45b2af2909 100644 --- a/homeassistant/components/nibe_heatpump/button.py +++ b/homeassistant/components/nibe_heatpump/button.py @@ -11,7 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DOMAIN, LOGGER, Coordinator +from .const import DOMAIN, LOGGER +from .coordinator import Coordinator async def async_setup_entry( diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index 4ab709ae947..38a3a5f825c 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -1,6 +1,7 @@ """The Nibe Heat Pump climate.""" from __future__ import annotations +from datetime import date from typing import Any from nibe.coil import Coil @@ -24,11 +25,9 @@ from homeassistant.components.climate import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import Coordinator from .const import ( DOMAIN, LOGGER, @@ -37,6 +36,7 @@ from .const import ( VALUES_PRIORITY_COOLING, VALUES_PRIORITY_HEATING, ) +from .coordinator import Coordinator async def async_setup_entry( @@ -48,10 +48,7 @@ async def async_setup_entry( coordinator: Coordinator = hass.data[DOMAIN][config_entry.entry_id] - main_unit = UNIT_COILGROUPS.get(coordinator.series, {}).get("main") - if not main_unit: - LOGGER.debug("Skipping climates - no main unit found") - return + main_unit = UNIT_COILGROUPS[coordinator.series]["main"] def climate_systems(): for key, group in CLIMATE_COILGROUPS.get(coordinator.series, ()).items(): @@ -128,10 +125,7 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): @callback def _handle_coordinator_update(self) -> None: - if not self.coordinator.data: - return - - def _get_value(coil: Coil) -> int | str | float | None: + def _get_value(coil: Coil) -> int | str | float | date | None: return self.coordinator.get_coil_value(coil) def _get_float(coil: Coil) -> float | None: @@ -179,7 +173,7 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): else: self._attr_hvac_action = HVACAction.IDLE else: - self._attr_hvac_action = None + self._attr_hvac_action = HVACAction.OFF self.async_write_ha_state() @@ -247,4 +241,4 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): ) await coordinator.async_write_coil(self._coil_use_room_sensor, "OFF") else: - raise HomeAssistantError(f"{hvac_mode} mode not supported for {self.name}") + raise ValueError(f"{hvac_mode} mode not supported for {self.name}") diff --git a/homeassistant/components/nibe_heatpump/coordinator.py b/homeassistant/components/nibe_heatpump/coordinator.py new file mode 100644 index 00000000000..ce75247083b --- /dev/null +++ b/homeassistant/components/nibe_heatpump/coordinator.py @@ -0,0 +1,234 @@ +"""The Nibe Heat Pump coordinator.""" +from __future__ import annotations + +import asyncio +from collections import defaultdict +from collections.abc import Callable, Iterable +from datetime import date, timedelta +from typing import Any, Generic, TypeVar + +from nibe.coil import Coil, CoilData +from nibe.connection import Connection +from nibe.exceptions import CoilNotFoundException, ReadException +from nibe.heatpump import HeatPump, Series + +from homeassistant.backports.functools import cached_property +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import DOMAIN, LOGGER + +_DataTypeT = TypeVar("_DataTypeT") +_ContextTypeT = TypeVar("_ContextTypeT") + + +class ContextCoordinator( + Generic[_DataTypeT, _ContextTypeT], DataUpdateCoordinator[_DataTypeT] +): + """Update coordinator with context adjustments.""" + + @cached_property + def context_callbacks(self) -> dict[_ContextTypeT, list[CALLBACK_TYPE]]: + """Return a dict of all callbacks registered for a given context.""" + callbacks: dict[_ContextTypeT, list[CALLBACK_TYPE]] = defaultdict(list) + for update_callback, context in list(self._listeners.values()): + assert isinstance(context, set) + for address in context: + callbacks[address].append(update_callback) + return callbacks + + @callback + def async_update_context_listeners(self, contexts: Iterable[_ContextTypeT]) -> None: + """Update all listeners given a set of contexts.""" + update_callbacks: set[CALLBACK_TYPE] = set() + for context in contexts: + update_callbacks.update(self.context_callbacks.get(context, [])) + + for update_callback in update_callbacks: + update_callback() + + @callback + def async_add_listener( + self, update_callback: CALLBACK_TYPE, context: Any = None + ) -> Callable[[], None]: + """Wrap standard function to prune cached callback database.""" + assert isinstance(context, set) + context -= {None} + release = super().async_add_listener(update_callback, context) + self.__dict__.pop("context_callbacks", None) + + @callback + def release_update(): + release() + self.__dict__.pop("context_callbacks", None) + + return release_update + + +class Coordinator(ContextCoordinator[dict[int, CoilData], int]): + """Update coordinator for nibe heat pumps.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + heatpump: HeatPump, + connection: Connection, + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, LOGGER, name="Nibe Heat Pump", update_interval=timedelta(seconds=60) + ) + + self.data = {} + self.seed: dict[int, CoilData] = {} + self.connection = connection + self.heatpump = heatpump + self.task: asyncio.Task | None = None + + heatpump.subscribe(heatpump.COIL_UPDATE_EVENT, self._on_coil_update) + + def _on_coil_update(self, data: CoilData): + """Handle callback on coil updates.""" + coil = data.coil + self.data[coil.address] = data + self.seed[coil.address] = data + self.async_update_context_listeners([coil.address]) + + @property + def series(self) -> Series: + """Return which series of pump we are connected to.""" + return self.heatpump.series + + @property + def coils(self) -> list[Coil]: + """Return the full coil database.""" + return self.heatpump.get_coils() + + @property + def unique_id(self) -> str: + """Return unique id for this coordinator.""" + return self.config_entry.unique_id or self.config_entry.entry_id + + @property + def device_info(self) -> DeviceInfo: + """Return device information for the main device.""" + return DeviceInfo(identifiers={(DOMAIN, self.unique_id)}) + + def get_coil_value(self, coil: Coil) -> int | str | float | date | None: + """Return a coil with data and check for validity.""" + if coil_with_data := self.data.get(coil.address): + return coil_with_data.value + return None + + def get_coil_float(self, coil: Coil) -> float | None: + """Return a coil with float and check for validity.""" + if value := self.get_coil_value(coil): + return float(value) # type: ignore[arg-type] + return None + + async def async_write_coil(self, coil: Coil, value: int | float | str) -> None: + """Write coil and update state.""" + data = CoilData(coil, value) + await self.connection.write_coil(data) + + self.data[coil.address] = data + + self.async_update_context_listeners([coil.address]) + + async def async_read_coil(self, coil: Coil) -> CoilData: + """Read coil and update state using callbacks.""" + return await self.connection.read_coil(coil) + + async def _async_update_data(self) -> dict[int, CoilData]: + self.task = asyncio.current_task() + try: + return await self._async_update_data_internal() + finally: + self.task = None + + async def _async_update_data_internal(self) -> dict[int, CoilData]: + result: dict[int, CoilData] = {} + + def _get_coils() -> Iterable[Coil]: + for address in sorted(self.context_callbacks.keys()): + if seed := self.seed.pop(address, None): + self.logger.debug("Skipping seeded coil: %d", address) + result[address] = seed + continue + + try: + coil = self.heatpump.get_coil_by_address(address) + except CoilNotFoundException as exception: + self.logger.debug("Skipping missing coil: %s", exception) + continue + yield coil + + try: + async for data in self.connection.read_coils(_get_coils()): + result[data.coil.address] = data + self.seed.pop(data.coil.address, None) + except ReadException as exception: + if not result: + raise UpdateFailed(f"Failed to update: {exception}") from exception + self.logger.debug( + "Some coils failed to update, and may be unsupported: %s", exception + ) + + return result + + async def async_shutdown(self): + """Make sure a coordinator is shut down as well as it's connection.""" + await super().async_shutdown() + if self.task: + self.task.cancel() + await asyncio.wait((self.task,)) + await self.connection.stop() + + +class CoilEntity(CoordinatorEntity[Coordinator]): + """Base for coil based entities.""" + + _attr_has_entity_name = True + _attr_entity_registry_enabled_default = False + + def __init__( + self, coordinator: Coordinator, coil: Coil, entity_format: str + ) -> None: + """Initialize base entity.""" + super().__init__(coordinator, {coil.address}) + self.entity_id = async_generate_entity_id( + entity_format, coil.name, hass=coordinator.hass + ) + self._attr_name = coil.title + self._attr_unique_id = f"{coordinator.unique_id}-{coil.address}" + self._attr_device_info = coordinator.device_info + self._coil = coil + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.last_update_success and self._coil.address in ( + self.coordinator.data or {} + ) + + def _async_read_coil(self, data: CoilData): + """Update state of entity based on coil data.""" + + async def _async_write_coil(self, value: int | float | str): + """Write coil and update state.""" + await self.coordinator.async_write_coil(self._coil, value) + + def _handle_coordinator_update(self) -> None: + data = self.coordinator.data.get(self._coil.address) + if data is not None: + self._async_read_coil(data) + self.async_write_ha_state() diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index 355ce84525f..94a2a76c814 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.4.0"] + "requirements": ["nibe==2.5.2"] } diff --git a/homeassistant/components/nibe_heatpump/number.py b/homeassistant/components/nibe_heatpump/number.py index 8231cc65450..83ccc124e51 100644 --- a/homeassistant/components/nibe_heatpump/number.py +++ b/homeassistant/components/nibe_heatpump/number.py @@ -9,7 +9,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, CoilEntity, Coordinator +from .const import DOMAIN +from .coordinator import CoilEntity, Coordinator async def async_setup_entry( @@ -65,7 +66,7 @@ class Number(CoilEntity, NumberEntity): return try: - self._attr_native_value = float(data.value) + self._attr_native_value = float(data.value) # type: ignore[arg-type] except ValueError: self._attr_native_value = None diff --git a/homeassistant/components/nibe_heatpump/select.py b/homeassistant/components/nibe_heatpump/select.py index e255ff36500..c4794cc18b7 100644 --- a/homeassistant/components/nibe_heatpump/select.py +++ b/homeassistant/components/nibe_heatpump/select.py @@ -9,7 +9,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, CoilEntity, Coordinator +from .const import DOMAIN +from .coordinator import CoilEntity, Coordinator async def async_setup_entry( diff --git a/homeassistant/components/nibe_heatpump/sensor.py b/homeassistant/components/nibe_heatpump/sensor.py index d9e89a2d56c..8c9439e6531 100644 --- a/homeassistant/components/nibe_heatpump/sensor.py +++ b/homeassistant/components/nibe_heatpump/sensor.py @@ -24,7 +24,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, CoilEntity, Coordinator +from .const import DOMAIN +from .coordinator import CoilEntity, Coordinator UNIT_DESCRIPTIONS = { "°C": SensorEntityDescription( diff --git a/homeassistant/components/nibe_heatpump/switch.py b/homeassistant/components/nibe_heatpump/switch.py index 16a7ef2b1f5..f55882d529c 100644 --- a/homeassistant/components/nibe_heatpump/switch.py +++ b/homeassistant/components/nibe_heatpump/switch.py @@ -11,7 +11,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, CoilEntity, Coordinator +from .const import DOMAIN +from .coordinator import CoilEntity, Coordinator async def async_setup_entry( diff --git a/homeassistant/components/nibe_heatpump/water_heater.py b/homeassistant/components/nibe_heatpump/water_heater.py index 0c606380776..db688fdb69c 100644 --- a/homeassistant/components/nibe_heatpump/water_heater.py +++ b/homeassistant/components/nibe_heatpump/water_heater.py @@ -1,6 +1,8 @@ """The Nibe Heat Pump sensors.""" from __future__ import annotations +from datetime import date + from nibe.coil import Coil from nibe.coil_groups import WATER_HEATER_COILGROUPS, WaterHeaterCoilGroup from nibe.exceptions import CoilNotFoundException @@ -17,8 +19,13 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DOMAIN, LOGGER, Coordinator -from .const import VALUES_TEMPORARY_LUX_INACTIVE, VALUES_TEMPORARY_LUX_ONE_TIME_INCREASE +from .const import ( + DOMAIN, + LOGGER, + VALUES_TEMPORARY_LUX_INACTIVE, + VALUES_TEMPORARY_LUX_ONE_TIME_INCREASE, +) +from .coordinator import Coordinator async def async_setup_entry( @@ -127,7 +134,7 @@ class WaterHeater(CoordinatorEntity[Coordinator], WaterHeaterEntity): return None return self.coordinator.get_coil_float(coil) - def _get_value(coil: Coil | None) -> int | str | float | None: + def _get_value(coil: Coil | None) -> int | str | float | date | None: if coil is None: return None return self.coordinator.get_coil_value(coil) diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index 568869ca402..92c7d16dc84 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -9,6 +9,7 @@ from homeassistant.components.binary_sensor import ( ) 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 homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -46,7 +47,9 @@ async def async_setup_entry( for ent in coordinator.data: for i in range(0, message_slots): - entities.append(NINAMessage(coordinator, ent, regions[ent], i + 1)) + entities.append( + NINAMessage(coordinator, ent, regions[ent], i + 1, config_entry) + ) async_add_entities(entities) @@ -54,12 +57,15 @@ async def async_setup_entry( class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEntity): """Representation of an NINA warning.""" + _attr_device_class = BinarySensorDeviceClass.SAFETY + def __init__( self, coordinator: NINADataUpdateCoordinator, region: str, region_name: str, slot_id: int, + config_entry: ConfigEntry, ) -> None: """Initialize.""" super().__init__(coordinator) @@ -69,7 +75,11 @@ class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEnti self._attr_name = f"Warning: {region_name} {slot_id}" self._attr_unique_id = f"{region}-{slot_id}" - self._attr_device_class = BinarySensorDeviceClass.SAFETY + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, config_entry.entry_id)}, + manufacturer="NINA", + entry_type=DeviceEntryType.SERVICE, + ) @property def is_on(self) -> bool: diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index bc2c328d647..6c77f98d1b1 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -4,26 +4,12 @@ from __future__ import annotations from pynobo import nobo from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_NAME, - CONF_IP_ADDRESS, - EVENT_HOMEASSISTANT_STOP, - Platform, -) +from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr -from .const import ( - ATTR_HARDWARE_VERSION, - ATTR_SERIAL, - ATTR_SOFTWARE_VERSION, - CONF_AUTO_DISCOVERED, - CONF_SERIAL, - DOMAIN, - NOBO_MANUFACTURER, -) +from .const import CONF_AUTO_DISCOVERED, CONF_SERIAL, DOMAIN -PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] +PLATFORMS = [Platform.CLIMATE, Platform.SELECT, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -37,17 +23,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) - # Register hub as device - dev_reg = dr.async_get(hass) - dev_reg.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, hub.hub_info[ATTR_SERIAL])}, - manufacturer=NOBO_MANUFACTURER, - name=hub.hub_info[ATTR_NAME], - model=f"Nobø Ecohub ({hub.hub_info[ATTR_HARDWARE_VERSION]})", - sw_version=hub.hub_info[ATTR_SOFTWARE_VERSION], - ) - async def _async_close(event): """Close the Nobø Ecohub socket connection when HA stops.""" await hub.stop() diff --git a/homeassistant/components/nobo_hub/manifest.json b/homeassistant/components/nobo_hub/manifest.json index 4e6009ce6d7..9ddbed7dadc 100644 --- a/homeassistant/components/nobo_hub/manifest.json +++ b/homeassistant/components/nobo_hub/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@echoromeo", "@oyvindwe"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nobo_hub", + "integration_type": "hub", "iot_class": "local_push", "requirements": ["pynobo==1.6.0"] } diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py new file mode 100644 index 00000000000..b386e158420 --- /dev/null +++ b/homeassistant/components/nobo_hub/select.py @@ -0,0 +1,170 @@ +"""Python Control of Nobø Hub - Nobø Energy Control.""" +from __future__ import annotations + +from pynobo import nobo + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + ATTR_HARDWARE_VERSION, + ATTR_SERIAL, + ATTR_SOFTWARE_VERSION, + CONF_OVERRIDE_TYPE, + DOMAIN, + NOBO_MANUFACTURER, + OVERRIDE_TYPE_NOW, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up any temperature sensors connected to the Nobø Ecohub.""" + + # Setup connection with hub + hub: nobo = hass.data[DOMAIN][config_entry.entry_id] + + override_type = ( + nobo.API.OVERRIDE_TYPE_NOW + if config_entry.options.get(CONF_OVERRIDE_TYPE) == OVERRIDE_TYPE_NOW + else nobo.API.OVERRIDE_TYPE_CONSTANT + ) + + entities: list[SelectEntity] = [ + NoboProfileSelector(zone_id, hub) for zone_id in hub.zones + ] + entities.append(NoboGlobalSelector(hub, override_type)) + async_add_entities(entities, True) + + +class NoboGlobalSelector(SelectEntity): + """Global override selector for Nobø Ecohub.""" + + _attr_has_entity_name = True + _attr_translation_key = "global_override" + _attr_device_class = "nobo_hub__override" + _attr_should_poll = False + _modes = { + nobo.API.OVERRIDE_MODE_NORMAL: "none", + nobo.API.OVERRIDE_MODE_AWAY: "away", + nobo.API.OVERRIDE_MODE_COMFORT: "comfort", + nobo.API.OVERRIDE_MODE_ECO: "eco", + } + _attr_options = list(_modes.values()) + _attr_current_option: str + + def __init__(self, hub: nobo, override_type) -> None: + """Initialize the global override selector.""" + self._nobo = hub + self._attr_unique_id = hub.hub_serial + self._override_type = override_type + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, hub.hub_serial)}, + name=hub.hub_info[ATTR_NAME], + manufacturer=NOBO_MANUFACTURER, + model=f"Nobø Ecohub ({hub.hub_info[ATTR_HARDWARE_VERSION]})", + sw_version=hub.hub_info[ATTR_SOFTWARE_VERSION], + ) + + async def async_added_to_hass(self) -> None: + """Register callback from hub.""" + self._nobo.register_callback(self._after_update) + + async def async_will_remove_from_hass(self) -> None: + """Deregister callback from hub.""" + self._nobo.deregister_callback(self._after_update) + + async def async_select_option(self, option: str) -> None: + """Set override.""" + mode = [k for k, v in self._modes.items() if v == option][0] + try: + await self._nobo.async_create_override( + mode, self._override_type, nobo.API.OVERRIDE_TARGET_GLOBAL + ) + except Exception as exp: + raise HomeAssistantError from exp + + async def async_update(self) -> None: + """Fetch new state data for this zone.""" + self._read_state() + + @callback + def _read_state(self) -> None: + for override in self._nobo.overrides.values(): + if override["target_type"] == nobo.API.OVERRIDE_TARGET_GLOBAL: + self._attr_current_option = self._modes[override["mode"]] + break + + @callback + def _after_update(self, hub) -> None: + self._read_state() + self.async_write_ha_state() + + +class NoboProfileSelector(SelectEntity): + """Week profile selector for Nobø zones.""" + + _attr_translation_key = "week_profile" + _attr_has_entity_name = True + _attr_should_poll = False + _profiles: dict[int, str] = {} + _attr_options: list[str] = [] + _attr_current_option: str + + def __init__(self, zone_id: str, hub: nobo) -> None: + """Initialize the week profile selector.""" + self._id = zone_id + self._nobo = hub + self._attr_unique_id = f"{hub.hub_serial}:{zone_id}:profile" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{hub.hub_serial}:{zone_id}")}, + name=hub.zones[zone_id][ATTR_NAME], + via_device=(DOMAIN, hub.hub_info[ATTR_SERIAL]), + suggested_area=hub.zones[zone_id][ATTR_NAME], + ) + + async def async_added_to_hass(self) -> None: + """Register callback from hub.""" + self._nobo.register_callback(self._after_update) + + async def async_will_remove_from_hass(self) -> None: + """Deregister callback from hub.""" + self._nobo.deregister_callback(self._after_update) + + async def async_select_option(self, option: str) -> None: + """Set week profile.""" + week_profile_id = [k for k, v in self._profiles.items() if v == option][0] + try: + await self._nobo.async_update_zone( + self._id, week_profile_id=week_profile_id + ) + except Exception as exp: + raise HomeAssistantError from exp + + async def async_update(self) -> None: + """Fetch new state data for this zone.""" + self._read_state() + + @callback + def _read_state(self) -> None: + self._profiles = { + profile["week_profile_id"]: profile["name"].replace("\xa0", " ") + for profile in self._nobo.week_profiles.values() + } + self._attr_options = sorted(self._profiles.values()) + self._attr_current_option = self._profiles[ + self._nobo.zones[self._id]["week_profile_id"] + ] + + @callback + def _after_update(self, hub) -> None: + self._read_state() + self.async_write_ha_state() diff --git a/homeassistant/components/nobo_hub/strings.json b/homeassistant/components/nobo_hub/strings.json index cfa339c98df..28be01862e9 100644 --- a/homeassistant/components/nobo_hub/strings.json +++ b/homeassistant/components/nobo_hub/strings.json @@ -40,5 +40,21 @@ "description": "Select override type \"Now\" to end override on next week profile change." } } + }, + "entity": { + "select": { + "global_override": { + "name": "global override", + "state": { + "away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]", + "eco": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", + "none": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::none%]" + } + }, + "week_profile": { + "name": "week profile" + } + } } } diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index 88605fdbdfd..036ef6e4f0e 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -189,8 +189,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise UpdateFailed( f"There was an unknown error while updating {attr}: {result}" ) from result + if isinstance(result, BaseException): + raise result from None - data.update_data_from_response(result) + data.update_data_from_response(result) # type: ignore[arg-type] return data diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index ede7a20ccdb..3f17c0b795b 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -39,7 +39,7 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) -from .const import DEFAULT_TIMEOUT, DOMAIN, ERROR_STATES +from .const import CONF_ENCRYPT_TOKEN, DEFAULT_TIMEOUT, DOMAIN, ERROR_STATES from .helpers import NukiWebhookException, parse_id _NukiDeviceT = TypeVar("_NukiDeviceT", bound=NukiDevice) @@ -188,7 +188,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_HOST], entry.data[CONF_TOKEN], entry.data[CONF_PORT], - True, + entry.data.get(CONF_ENCRYPT_TOKEN, True), DEFAULT_TIMEOUT, ) diff --git a/homeassistant/components/nuki/config_flow.py b/homeassistant/components/nuki/config_flow.py index 310197d55d8..4acfecf492b 100644 --- a/homeassistant/components/nuki/config_flow.py +++ b/homeassistant/components/nuki/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.data_entry_flow import FlowResult -from .const import DEFAULT_PORT, DEFAULT_TIMEOUT, DOMAIN +from .const import CONF_ENCRYPT_TOKEN, DEFAULT_PORT, DEFAULT_TIMEOUT, DOMAIN from .helpers import CannotConnect, InvalidAuth, parse_id _LOGGER = logging.getLogger(__name__) @@ -26,7 +26,12 @@ USER_SCHEMA = vol.Schema( } ) -REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_TOKEN): str}) +REAUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_TOKEN): str, + vol.Optional(CONF_ENCRYPT_TOKEN, default=True): bool, + } +) async def validate_input(hass, data): @@ -41,7 +46,7 @@ async def validate_input(hass, data): data[CONF_HOST], data[CONF_TOKEN], data[CONF_PORT], - True, + data.get(CONF_ENCRYPT_TOKEN, True), DEFAULT_TIMEOUT, ) @@ -100,6 +105,7 @@ class NukiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONF_HOST: self._data[CONF_HOST], CONF_PORT: self._data[CONF_PORT], CONF_TOKEN: user_input[CONF_TOKEN], + CONF_ENCRYPT_TOKEN: user_input[CONF_ENCRYPT_TOKEN], } try: @@ -131,8 +137,15 @@ class NukiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_validate(self, user_input=None): """Handle init step of a flow.""" + data_schema = self.discovery_schema or USER_SCHEMA + errors = {} if user_input is not None: + data_schema = USER_SCHEMA.extend( + { + vol.Optional(CONF_ENCRYPT_TOKEN, default=True): bool, + } + ) try: info = await validate_input(self.hass, user_input) except CannotConnect: @@ -149,7 +162,8 @@ class NukiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return self.async_create_entry(title=bridge_id, data=user_input) - data_schema = self.discovery_schema or USER_SCHEMA return self.async_show_form( - step_id="user", data_schema=data_schema, errors=errors + step_id="user", + data_schema=self.add_suggested_values_to_schema(data_schema, user_input), + errors=errors, ) diff --git a/homeassistant/components/nuki/const.py b/homeassistant/components/nuki/const.py index dee4a8b8ac5..21a2dcf9e5b 100644 --- a/homeassistant/components/nuki/const.py +++ b/homeassistant/components/nuki/const.py @@ -12,3 +12,6 @@ DEFAULT_PORT = 8080 DEFAULT_TIMEOUT = 20 ERROR_STATES = (0, 254, 255) + +# Encrypt token, instead of using a plaintext token +CONF_ENCRYPT_TOKEN = "encrypt_token" diff --git a/homeassistant/components/nuki/strings.json b/homeassistant/components/nuki/strings.json index 19aeae989f4..216b891ac31 100644 --- a/homeassistant/components/nuki/strings.json +++ b/homeassistant/components/nuki/strings.json @@ -5,14 +5,19 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", - "token": "[%key:common::config_flow::data::access_token%]" + "token": "[%key:common::config_flow::data::access_token%]", + "encrypt_token": "Use an encrypted token for authentication." + }, + "data_description": { + "host": "The hostname or IP address of your Nuki bridge. For example: 192.168.1.25." } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The Nuki integration needs to re-authenticate with your bridge.", "data": { - "token": "[%key:common::config_flow::data::access_token%]" + "token": "[%key:common::config_flow::data::access_token%]", + "encrypt_token": "[%key:component::nuki::config::step::user::data::encrypt_token%]" } } }, diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 2827911a3aa..7347744d56f 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -2,12 +2,15 @@ "config": { "step": { "user": { - "title": "Connect to the NUT server", + "description": "Connect to the NUT server", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your NUT server." } }, "ups": { diff --git a/homeassistant/components/nws/manifest.json b/homeassistant/components/nws/manifest.json index 05194d85a26..4006a145db4 100644 --- a/homeassistant/components/nws/manifest.json +++ b/homeassistant/components/nws/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["metar", "pynws"], "quality_scale": "platinum", - "requirements": ["pynws==1.5.1"] + "requirements": ["pynws==1.6.0"] } diff --git a/homeassistant/components/obihai/strings.json b/homeassistant/components/obihai/strings.json index 823bc2e1b8d..f21b4b3706d 100644 --- a/homeassistant/components/obihai/strings.json +++ b/homeassistant/components/obihai/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The hostname or IP address of your Obihai device." } }, "dhcp_confirm": { @@ -14,6 +17,9 @@ "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "[%key:component::obihai::config::step::user::data_description::host%]" } } }, diff --git a/homeassistant/components/octoprint/camera.py b/homeassistant/components/octoprint/camera.py index 99052993a61..a6955706508 100644 --- a/homeassistant/components/octoprint/camera.py +++ b/homeassistant/components/octoprint/camera.py @@ -7,8 +7,8 @@ from homeassistant.components.mjpeg.camera import MjpegCamera from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import OctoprintDataUpdateCoordinator from .const import DOMAIN @@ -38,7 +38,7 @@ async def async_setup_entry( [ OctoprintCamera( camera_info, - coordinator.device_info, + coordinator, device_id, verify_ssl, ) @@ -46,19 +46,23 @@ async def async_setup_entry( ) -class OctoprintCamera(MjpegCamera): +class OctoprintCamera(CoordinatorEntity[OctoprintDataUpdateCoordinator], MjpegCamera): """Representation of an OctoPrint Camera Stream.""" def __init__( self, camera_settings: WebcamSettings, - device_info: DeviceInfo, + coordinator: OctoprintDataUpdateCoordinator, device_id: str, verify_ssl: bool, ) -> None: """Initialize as a subclass of MjpegCamera.""" super().__init__( - device_info=device_info, + coordinator=coordinator, + ) + MjpegCamera.__init__( + self, + device_info=coordinator.device_info, mjpeg_url=camera_settings.stream_url, name="OctoPrint Camera", still_image_url=camera_settings.external_snapshot_url, diff --git a/homeassistant/components/octoprint/strings.json b/homeassistant/components/octoprint/strings.json index c6dbfe6f9c4..63d9753ee1d 100644 --- a/homeassistant/components/octoprint/strings.json +++ b/homeassistant/components/octoprint/strings.json @@ -10,6 +10,9 @@ "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The hostname or IP address of your printer." } }, "reauth_confirm": { diff --git a/homeassistant/components/ondilo_ico/strings.json b/homeassistant/components/ondilo_ico/strings.json index 3843670bc50..26199b1bd75 100644 --- a/homeassistant/components/ondilo_ico/strings.json +++ b/homeassistant/components/ondilo_ico/strings.json @@ -7,7 +7,11 @@ }, "abort": { "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]" + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 9e4120b68b2..753f244cfe9 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -12,6 +12,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, + "data_description": { + "host": "The hostname or IP address of your 1-Wire device." + }, "title": "Set server details" } } diff --git a/homeassistant/components/onvif/base.py b/homeassistant/components/onvif/base.py index 8771ae7a701..5f8a7d978d1 100644 --- a/homeassistant/components/onvif/base.py +++ b/homeassistant/components/onvif/base.py @@ -32,8 +32,7 @@ class ONVIFBaseEntity(Entity): See: https://github.com/home-assistant/core/issues/35883 """ return ( - self.device.info.mac - or self.device.info.serial_number # type:ignore[return-value] + self.device.info.mac or self.device.info.serial_number # type:ignore[return-value] ) @property diff --git a/homeassistant/components/onvif/strings.json b/homeassistant/components/onvif/strings.json index cabab347264..5a36b89688a 100644 --- a/homeassistant/components/onvif/strings.json +++ b/homeassistant/components/onvif/strings.json @@ -36,6 +36,9 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, + "data_description": { + "host": "The hostname or IP address of your ONVIF device." + }, "title": "Configure ONVIF device" }, "configure_profile": { diff --git a/homeassistant/components/open_meteo/diagnostics.py b/homeassistant/components/open_meteo/diagnostics.py index a429b0c368f..a88325066fe 100644 --- a/homeassistant/components/open_meteo/diagnostics.py +++ b/homeassistant/components/open_meteo/diagnostics.py @@ -1,7 +1,6 @@ """Diagnostics support for Open-Meteo.""" from __future__ import annotations -import json from typing import Any from open_meteo import Forecast @@ -25,6 +24,4 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" coordinator: DataUpdateCoordinator[Forecast] = hass.data[DOMAIN][entry.entry_id] - # Round-trip via JSON to trigger serialization - data: dict[str, Any] = json.loads(coordinator.data.json()) - return async_redact_data(data, TO_REDACT) + return async_redact_data(coordinator.data.to_dict(), TO_REDACT) diff --git a/homeassistant/components/open_meteo/manifest.json b/homeassistant/components/open_meteo/manifest.json index 1819a1deaa8..abdb59a48d0 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.2.1"] + "requirements": ["open-meteo==0.3.1"] } diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 9f4c30d91ba..054ccbdbe37 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -89,7 +89,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await hass.async_add_executor_job( partial( - openai.Engine.list, + openai.Model.list, api_key=entry.data[CONF_API_KEY], request_timeout=10, ) @@ -141,7 +141,7 @@ class OpenAIAgent(conversation.AbstractConversationAgent): conversation_id = user_input.conversation_id messages = self.history[conversation_id] else: - conversation_id = ulid.ulid() + conversation_id = ulid.ulid_now() try: prompt = self._async_generate_prompt(raw_prompt) except TemplateError as err: diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index b391f531eb1..9c5ef32d796 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -60,7 +60,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ openai.api_key = data[CONF_API_KEY] - await hass.async_add_executor_job(partial(openai.Engine.list, request_timeout=10)) + await hass.async_add_executor_job(partial(openai.Model.list, request_timeout=10)) class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/openexchangerates/sensor.py b/homeassistant/components/openexchangerates/sensor.py index 70f2f670de8..66baf54c16a 100644 --- a/homeassistant/components/openexchangerates/sensor.py +++ b/homeassistant/components/openexchangerates/sensor.py @@ -64,4 +64,4 @@ class OpenexchangeratesSensor( @property def native_value(self) -> float: """Return the state of the sensor.""" - return round(self.coordinator.data.rates[self._quote], 4) + return self.coordinator.data.rates[self._quote] diff --git a/homeassistant/components/opengarage/strings.json b/homeassistant/components/opengarage/strings.json index ba4521d4dcf..f19b458cd0f 100644 --- a/homeassistant/components/opengarage/strings.json +++ b/homeassistant/components/opengarage/strings.json @@ -7,6 +7,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of your OpenGarage device." } } }, diff --git a/homeassistant/components/ourgroceries/__init__.py b/homeassistant/components/ourgroceries/__init__.py new file mode 100644 index 00000000000..d645b8617c2 --- /dev/null +++ b/homeassistant/components/ourgroceries/__init__.py @@ -0,0 +1,50 @@ +"""The OurGroceries integration.""" +from __future__ import annotations + +from asyncio import TimeoutError as AsyncIOTimeoutError + +from aiohttp import ClientError +from ourgroceries import OurGroceries +from ourgroceries.exceptions import InvalidLoginException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .coordinator import OurGroceriesDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.TODO] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up OurGroceries from a config entry.""" + + hass.data.setdefault(DOMAIN, {}) + data = entry.data + og = OurGroceries(data[CONF_USERNAME], data[CONF_PASSWORD]) + lists = [] + try: + await og.login() + lists = (await og.get_my_lists())["shoppingLists"] + except (AsyncIOTimeoutError, ClientError) as error: + raise ConfigEntryNotReady from error + except InvalidLoginException: + return False + + coordinator = OurGroceriesDataUpdateCoordinator(hass, og, lists) + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/ourgroceries/config_flow.py b/homeassistant/components/ourgroceries/config_flow.py new file mode 100644 index 00000000000..a982325fceb --- /dev/null +++ b/homeassistant/components/ourgroceries/config_flow.py @@ -0,0 +1,57 @@ +"""Config flow for OurGroceries integration.""" +from __future__ import annotations + +from asyncio import TimeoutError as AsyncIOTimeoutError +import logging +from typing import Any + +from aiohttp import ClientError +from ourgroceries import OurGroceries +from ourgroceries.exceptions import InvalidLoginException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for OurGroceries.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + og = OurGroceries(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) + try: + await og.login() + except (AsyncIOTimeoutError, ClientError): + errors["base"] = "cannot_connect" + except InvalidLoginException: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/ourgroceries/const.py b/homeassistant/components/ourgroceries/const.py new file mode 100644 index 00000000000..ba0ff789522 --- /dev/null +++ b/homeassistant/components/ourgroceries/const.py @@ -0,0 +1,3 @@ +"""Constants for the OurGroceries integration.""" + +DOMAIN = "ourgroceries" diff --git a/homeassistant/components/ourgroceries/coordinator.py b/homeassistant/components/ourgroceries/coordinator.py new file mode 100644 index 00000000000..636ebcc300a --- /dev/null +++ b/homeassistant/components/ourgroceries/coordinator.py @@ -0,0 +1,47 @@ +"""The OurGroceries coordinator.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging + +from ourgroceries import OurGroceries + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +SCAN_INTERVAL = 60 + +_LOGGER = logging.getLogger(__name__) + + +class OurGroceriesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): + """Class to manage fetching OurGroceries data.""" + + def __init__( + self, hass: HomeAssistant, og: OurGroceries, lists: list[dict] + ) -> None: + """Initialize global OurGroceries data updater.""" + self.og = og + self.lists = lists + self._ids = [sl["id"] for sl in lists] + interval = timedelta(seconds=SCAN_INTERVAL) + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=interval, + ) + + async def _async_update_data(self) -> dict[str, dict]: + """Fetch data from OurGroceries.""" + return dict( + zip( + self._ids, + await asyncio.gather( + *[self.og.get_list_items(list_id=id) for id in self._ids] + ), + ) + ) diff --git a/homeassistant/components/ourgroceries/manifest.json b/homeassistant/components/ourgroceries/manifest.json new file mode 100644 index 00000000000..ec5a5039b39 --- /dev/null +++ b/homeassistant/components/ourgroceries/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "ourgroceries", + "name": "OurGroceries", + "codeowners": ["@OnFreund"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ourgroceries", + "iot_class": "cloud_polling", + "requirements": ["ourgroceries==1.5.4"] +} diff --git a/homeassistant/components/ourgroceries/strings.json b/homeassistant/components/ourgroceries/strings.json new file mode 100644 index 00000000000..78a46954183 --- /dev/null +++ b/homeassistant/components/ourgroceries/strings.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/ourgroceries/todo.py b/homeassistant/components/ourgroceries/todo.py new file mode 100644 index 00000000000..8115066d0fb --- /dev/null +++ b/homeassistant/components/ourgroceries/todo.py @@ -0,0 +1,119 @@ +"""A todo platform for OurGroceries.""" + +import asyncio +from typing import Any + +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) +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 + +from .const import DOMAIN +from .coordinator import OurGroceriesDataUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the OurGroceries todo platform config entry.""" + coordinator: OurGroceriesDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + OurGroceriesTodoListEntity(coordinator, sl["id"], sl["name"]) + for sl in coordinator.lists + ) + + +def _completion_status(item: dict[str, Any]) -> TodoItemStatus: + if item.get("crossedOffAt", False): + return TodoItemStatus.COMPLETED + return TodoItemStatus.NEEDS_ACTION + + +class OurGroceriesTodoListEntity( + CoordinatorEntity[OurGroceriesDataUpdateCoordinator], TodoListEntity +): + """An OurGroceries TodoListEntity.""" + + _attr_has_entity_name = True + _attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM + ) + + def __init__( + self, + coordinator: OurGroceriesDataUpdateCoordinator, + list_id: str, + list_name: str, + ) -> None: + """Initialize TodoistTodoListEntity.""" + super().__init__(coordinator=coordinator) + self._list_id = list_id + self._attr_unique_id = list_id + self._attr_name = list_name + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self.coordinator.data is None: + self._attr_todo_items = None + else: + self._attr_todo_items = [ + TodoItem( + summary=item["name"], + uid=item["id"], + status=_completion_status(item), + ) + for item in self.coordinator.data[self._list_id]["list"]["items"] + ] + super()._handle_coordinator_update() + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Create a To-do item.""" + if item.status != TodoItemStatus.NEEDS_ACTION: + raise ValueError("Only active tasks may be created.") + await self.coordinator.og.add_item_to_list( + self._list_id, item.summary, auto_category=True + ) + await self.coordinator.async_refresh() + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update a To-do item.""" + if item.summary: + api_items = self.coordinator.data[self._list_id]["list"]["items"] + category = next( + api_item["categoryId"] + for api_item in api_items + if api_item["id"] == item.uid + ) + await self.coordinator.og.change_item_on_list( + self._list_id, item.uid, category, item.summary + ) + if item.status is not None: + cross_off = item.status == TodoItemStatus.COMPLETED + await self.coordinator.og.toggle_item_crossed_off( + self._list_id, item.uid, cross_off=cross_off + ) + await self.coordinator.async_refresh() + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Delete a To-do item.""" + await asyncio.gather( + *[ + self.coordinator.og.remove_item_from_list(self._list_id, uid) + for uid in uids + ] + ) + await self.coordinator.async_refresh() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass update state from existing coordinator data.""" + await super().async_added_to_hass() + self._handle_coordinator_update() diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index 6ca082ace76..ebc3f96a7f5 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -4,26 +4,37 @@ from __future__ import annotations import asyncio from collections import defaultdict from dataclasses import dataclass +from typing import cast -from aiohttp import ClientError, ServerDisconnectedError +from aiohttp import ClientError from pyoverkiz.client import OverkizClient from pyoverkiz.const import SUPPORTED_SERVERS -from pyoverkiz.enums import OverkizState, UIClass, UIWidget +from pyoverkiz.enums import APIType, OverkizState, UIClass, UIWidget from pyoverkiz.exceptions import ( BadCredentialsException, MaintenanceException, + NotSuchTokenException, TooManyRequestsException, ) -from pyoverkiz.models import Device, Scenario +from pyoverkiz.models import Device, OverkizServer, Scenario, Setup +from pyoverkiz.utils import generate_local_server from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, + CONF_VERIFY_SSL, + Platform, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import ( + CONF_API_TYPE, CONF_HUB, DOMAIN, LOGGER, @@ -46,15 +57,26 @@ class HomeAssistantOverkizData: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Overkiz from a config entry.""" - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - server = SUPPORTED_SERVERS[entry.data[CONF_HUB]] + client: OverkizClient | None = None + api_type = entry.data.get(CONF_API_TYPE, APIType.CLOUD) - # To allow users with multiple accounts/hubs, we create a new session so they have separate cookies - session = async_create_clientsession(hass) - client = OverkizClient( - username=username, password=password, session=session, server=server - ) + # Local API + if api_type == APIType.LOCAL: + client = create_local_client( + hass, + host=entry.data[CONF_HOST], + token=entry.data[CONF_TOKEN], + verify_ssl=entry.data[CONF_VERIFY_SSL], + ) + + # Overkiz Cloud API + else: + client = create_cloud_client( + hass, + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + server=SUPPORTED_SERVERS[entry.data[CONF_HUB]], + ) await _async_migrate_entries(hass, entry) @@ -67,15 +89,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client.get_scenarios(), ] ) - except BadCredentialsException as exception: + except (BadCredentialsException, NotSuchTokenException) as exception: raise ConfigEntryAuthFailed("Invalid authentication") from exception except TooManyRequestsException as exception: raise ConfigEntryNotReady("Too many requests, try again later") from exception - except (TimeoutError, ClientError, ServerDisconnectedError) as exception: + except (TimeoutError, ClientError) as exception: raise ConfigEntryNotReady("Failed to connect") from exception except MaintenanceException as exception: raise ConfigEntryNotReady("Server is down for maintenance") from exception + setup = cast(Setup, setup) + scenarios = cast(list[Scenario], scenarios) + coordinator = OverkizDataUpdateCoordinator( hass, LOGGER, @@ -129,10 +154,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: config_entry_id=entry.entry_id, identifiers={(DOMAIN, gateway.id)}, model=gateway.sub_type.beautify_name if gateway.sub_type else None, - manufacturer=server.manufacturer, + manufacturer=client.server.manufacturer, name=gateway.type.beautify_name if gateway.type else gateway.id, sw_version=gateway.connectivity.protocol_version, - configuration_url=server.configuration_url, + configuration_url=client.server.configuration_url, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -206,3 +231,31 @@ async def _async_migrate_entries( await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) return True + + +def create_local_client( + hass: HomeAssistant, host: str, token: str, verify_ssl: bool +) -> OverkizClient: + """Create Overkiz local client.""" + session = async_create_clientsession(hass, verify_ssl=verify_ssl) + + return OverkizClient( + username="", + password="", + token=token, + session=session, + server=generate_local_server(host=host), + verify_ssl=verify_ssl, + ) + + +def create_cloud_client( + hass: HomeAssistant, username: str, password: str, server: OverkizServer +) -> OverkizClient: + """Create Overkiz cloud client.""" + # To allow users with multiple accounts/hubs, we create a new session so they have separate cookies + session = async_create_clientsession(hass) + + return OverkizClient( + username=username, password=password, session=session, server=server + ) diff --git a/homeassistant/components/overkiz/climate.py b/homeassistant/components/overkiz/climate.py index a94c731ec8f..b6d31a8e685 100644 --- a/homeassistant/components/overkiz/climate.py +++ b/homeassistant/components/overkiz/climate.py @@ -7,7 +7,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeAssistantOverkizData -from .climate_entities import WIDGET_TO_CLIMATE_ENTITY +from .climate_entities import ( + WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY, + WIDGET_TO_CLIMATE_ENTITY, +) from .const import DOMAIN @@ -24,3 +27,13 @@ async def async_setup_entry( for device in data.platforms[Platform.CLIMATE] if device.widget in WIDGET_TO_CLIMATE_ENTITY ) + + # Hitachi Air To Air Heat Pumps + async_add_entities( + WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget][device.protocol]( + device.device_url, data.coordinator + ) + for device in data.platforms[Platform.CLIMATE] + if device.widget in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY + and device.protocol in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget] + ) diff --git a/homeassistant/components/overkiz/climate_entities/__init__.py b/homeassistant/components/overkiz/climate_entities/__init__.py index 9d54c04422a..c74ff2829cc 100644 --- a/homeassistant/components/overkiz/climate_entities/__init__.py +++ b/homeassistant/components/overkiz/climate_entities/__init__.py @@ -1,4 +1,5 @@ """Climate entities for the Overkiz (by Somfy) integration.""" +from pyoverkiz.enums import Protocol from pyoverkiz.enums.ui import UIWidget from .atlantic_electrical_heater import AtlanticElectricalHeater @@ -9,6 +10,8 @@ from .atlantic_electrical_towel_dryer import AtlanticElectricalTowelDryer from .atlantic_heat_recovery_ventilation import AtlanticHeatRecoveryVentilation from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone from .atlantic_pass_apc_zone_control import AtlanticPassAPCZoneControl +from .hitachi_air_to_air_heat_pump_hlrrwifi import HitachiAirToAirHeatPumpHLRRWIFI +from .somfy_heating_temperature_interface import SomfyHeatingTemperatureInterface from .somfy_thermostat import SomfyThermostat from .valve_heating_temperature_interface import ValveHeatingTemperatureInterface @@ -21,6 +24,14 @@ WIDGET_TO_CLIMATE_ENTITY = { UIWidget.ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE: AtlanticPassAPCHeatingZone, UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: AtlanticPassAPCHeatingZone, UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: AtlanticPassAPCZoneControl, + UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: SomfyHeatingTemperatureInterface, UIWidget.SOMFY_THERMOSTAT: SomfyThermostat, UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: ValveHeatingTemperatureInterface, } + +# Hitachi air-to-air heatpumps come in 2 flavors (HLRRWIFI and OVP) that are separated in 2 classes +WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY = { + UIWidget.HITACHI_AIR_TO_AIR_HEAT_PUMP: { + Protocol.HLRR_WIFI: HitachiAirToAirHeatPumpHLRRWIFI, + }, +} diff --git a/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_hlrrwifi.py b/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_hlrrwifi.py new file mode 100644 index 00000000000..7a9e50d7130 --- /dev/null +++ b/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_hlrrwifi.py @@ -0,0 +1,280 @@ +"""Support for HitachiAirToAirHeatPump.""" +from __future__ import annotations + +from typing import Any, cast + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + PRESET_NONE, + SWING_BOTH, + SWING_HORIZONTAL, + SWING_OFF, + SWING_VERTICAL, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature + +from ..const import DOMAIN +from ..coordinator import OverkizDataUpdateCoordinator +from ..entity import OverkizEntity + +PRESET_HOLIDAY_MODE = "holiday_mode" +FAN_SILENT = "silent" +FAN_SPEED_STATE = OverkizState.HLRRWIFI_FAN_SPEED +LEAVE_HOME_STATE = OverkizState.HLRRWIFI_LEAVE_HOME +MAIN_OPERATION_STATE = OverkizState.HLRRWIFI_MAIN_OPERATION +MODE_CHANGE_STATE = OverkizState.HLRRWIFI_MODE_CHANGE +ROOM_TEMPERATURE_STATE = OverkizState.HLRRWIFI_ROOM_TEMPERATURE +SWING_STATE = OverkizState.HLRRWIFI_SWING + +OVERKIZ_TO_HVAC_MODES: dict[str, HVACMode] = { + OverkizCommandParam.AUTOHEATING: HVACMode.AUTO, + OverkizCommandParam.AUTOCOOLING: HVACMode.AUTO, + OverkizCommandParam.ON: HVACMode.HEAT, + OverkizCommandParam.OFF: HVACMode.OFF, + OverkizCommandParam.HEATING: HVACMode.HEAT, + OverkizCommandParam.FAN: HVACMode.FAN_ONLY, + OverkizCommandParam.DEHUMIDIFY: HVACMode.DRY, + OverkizCommandParam.COOLING: HVACMode.COOL, + OverkizCommandParam.AUTO: HVACMode.AUTO, +} + +HVAC_MODES_TO_OVERKIZ: dict[HVACMode, str] = { + HVACMode.AUTO: OverkizCommandParam.AUTO, + HVACMode.HEAT: OverkizCommandParam.HEATING, + HVACMode.OFF: OverkizCommandParam.AUTO, + HVACMode.FAN_ONLY: OverkizCommandParam.FAN, + HVACMode.DRY: OverkizCommandParam.DEHUMIDIFY, + HVACMode.COOL: OverkizCommandParam.COOLING, +} + +OVERKIZ_TO_SWING_MODES: dict[str, str] = { + OverkizCommandParam.BOTH: SWING_BOTH, + OverkizCommandParam.HORIZONTAL: SWING_HORIZONTAL, + OverkizCommandParam.STOP: SWING_OFF, + OverkizCommandParam.VERTICAL: SWING_VERTICAL, +} + +SWING_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_SWING_MODES.items()} + +OVERKIZ_TO_FAN_MODES: dict[str, str] = { + OverkizCommandParam.AUTO: FAN_AUTO, + OverkizCommandParam.HIGH: FAN_HIGH, + OverkizCommandParam.LOW: FAN_LOW, + OverkizCommandParam.MEDIUM: FAN_MEDIUM, + OverkizCommandParam.SILENT: FAN_SILENT, +} + +FAN_MODES_TO_OVERKIZ: dict[str, str] = { + FAN_AUTO: OverkizCommandParam.AUTO, + FAN_HIGH: OverkizCommandParam.HIGH, + FAN_LOW: OverkizCommandParam.LOW, + FAN_MEDIUM: OverkizCommandParam.MEDIUM, + FAN_SILENT: OverkizCommandParam.SILENT, +} + + +class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity): + """Representation of Hitachi Air To Air HeatPump.""" + + _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] + _attr_preset_modes = [PRESET_NONE, PRESET_HOLIDAY_MODE] + _attr_swing_modes = [*SWING_MODES_TO_OVERKIZ] + _attr_target_temperature_step = 1.0 + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = DOMAIN + + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Init method.""" + super().__init__(device_url, coordinator) + + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE + ) + + if self.device.states.get(SWING_STATE): + self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + + if self._attr_device_info: + self._attr_device_info["manufacturer"] = "Hitachi" + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool mode.""" + if ( + main_op_state := self.device.states[MAIN_OPERATION_STATE] + ) and main_op_state.value_as_str: + if main_op_state.value_as_str.lower() == OverkizCommandParam.OFF: + return HVACMode.OFF + + if ( + mode_change_state := self.device.states[MODE_CHANGE_STATE] + ) and mode_change_state.value_as_str: + sanitized_value = mode_change_state.value_as_str.lower() + return OVERKIZ_TO_HVAC_MODES[sanitized_value] + + return HVACMode.OFF + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + if hvac_mode == HVACMode.OFF: + await self._global_control(main_operation=OverkizCommandParam.OFF) + else: + await self._global_control( + main_operation=OverkizCommandParam.ON, + hvac_mode=HVAC_MODES_TO_OVERKIZ[hvac_mode], + ) + + @property + def fan_mode(self) -> str | None: + """Return the fan setting.""" + if (state := self.device.states[FAN_SPEED_STATE]) and state.value_as_str: + return OVERKIZ_TO_FAN_MODES[state.value_as_str] + + return None + + @property + def fan_modes(self) -> list[str] | None: + """Return the list of available fan modes.""" + return [*FAN_MODES_TO_OVERKIZ] + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + await self._global_control(fan_mode=FAN_MODES_TO_OVERKIZ[fan_mode]) + + @property + def swing_mode(self) -> str | None: + """Return the swing setting.""" + if (state := self.device.states[SWING_STATE]) and state.value_as_str: + return OVERKIZ_TO_SWING_MODES[state.value_as_str] + + return None + + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set new target swing operation.""" + await self._global_control(swing_mode=SWING_MODES_TO_OVERKIZ[swing_mode]) + + @property + def target_temperature(self) -> int | None: + """Return the temperature.""" + if ( + temperature := self.device.states[OverkizState.CORE_TARGET_TEMPERATURE] + ) and temperature.value_as_int: + return temperature.value_as_int + + return None + + @property + def current_temperature(self) -> int | None: + """Return current temperature.""" + if (state := self.device.states[ROOM_TEMPERATURE_STATE]) and state.value_as_int: + return state.value_as_int + + return None + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new temperature.""" + temperature = cast(float, kwargs.get(ATTR_TEMPERATURE)) + await self._global_control(target_temperature=int(temperature)) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., home, away, temp.""" + if (state := self.device.states[LEAVE_HOME_STATE]) and state.value_as_str: + if state.value_as_str == OverkizCommandParam.ON: + return PRESET_HOLIDAY_MODE + + if state.value_as_str == OverkizCommandParam.OFF: + return PRESET_NONE + + return None + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if preset_mode == PRESET_HOLIDAY_MODE: + await self._global_control(leave_home=OverkizCommandParam.ON) + + if preset_mode == PRESET_NONE: + await self._global_control(leave_home=OverkizCommandParam.OFF) + + def _control_backfill( + self, value: str | None, state_name: str, fallback_value: str + ) -> str: + """Overkiz doesn't accept commands with undefined parameters. This function is guaranteed to return a `str` which is the provided `value` if set, or the current device state if set, or the provided `fallback_value` otherwise.""" + if value: + return value + state = self.device.states[state_name] + if state and state.value_as_str: + return state.value_as_str + return fallback_value + + async def _global_control( + self, + main_operation: str | None = None, + target_temperature: int | None = None, + fan_mode: str | None = None, + hvac_mode: str | None = None, + swing_mode: str | None = None, + leave_home: str | None = None, + ) -> None: + """Execute globalControl command with all parameters. There is no option to only set a single parameter, without passing all other values.""" + + main_operation = self._control_backfill( + main_operation, MAIN_OPERATION_STATE, OverkizCommandParam.ON + ) + target_temperature = target_temperature or self.target_temperature + + fan_mode = self._control_backfill( + fan_mode, + FAN_SPEED_STATE, + OverkizCommandParam.AUTO, + ) + hvac_mode = self._control_backfill( + hvac_mode, + MODE_CHANGE_STATE, + OverkizCommandParam.AUTO, + ).lower() # Overkiz can return states that have uppercase characters which are not accepted back as commands + if ( + hvac_mode.replace(" ", "") + in [ # Overkiz can return states like 'auto cooling' or 'autoHeating' that are not valid commands and need to be converted to 'auto' + OverkizCommandParam.AUTOCOOLING, + OverkizCommandParam.AUTOHEATING, + ] + ): + hvac_mode = OverkizCommandParam.AUTO + + swing_mode = self._control_backfill( + swing_mode, + SWING_STATE, + OverkizCommandParam.STOP, + ) + + leave_home = self._control_backfill( + leave_home, + LEAVE_HOME_STATE, + OverkizCommandParam.OFF, + ) + + command_data = [ + main_operation, # Main Operation + target_temperature, # Target Temperature + fan_mode, # Fan Mode + hvac_mode, # Mode + swing_mode, # Swing Mode + leave_home, # Leave Home + ] + + await self.executor.async_execute_command( + OverkizCommand.GLOBAL_CONTROL, *command_data + ) diff --git a/homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py b/homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py new file mode 100644 index 00000000000..6c3ee3454ce --- /dev/null +++ b/homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py @@ -0,0 +1,179 @@ +"""Support for Somfy Heating Temperature Interface.""" +from __future__ import annotations + +from typing import Any + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.climate import ( + PRESET_AWAY, + PRESET_COMFORT, + PRESET_ECO, + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature + +from ..coordinator import OverkizDataUpdateCoordinator +from ..entity import OverkizEntity + +OVERKIZ_TO_PRESET_MODES: dict[str, str] = { + OverkizCommandParam.SECURED: PRESET_AWAY, + OverkizCommandParam.ECO: PRESET_ECO, + OverkizCommandParam.COMFORT: PRESET_COMFORT, + OverkizCommandParam.FREE: PRESET_NONE, +} + +PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODES.items()} + +OVERKIZ_TO_HVAC_MODES: dict[str, HVACMode] = { + OverkizCommandParam.AUTO: HVACMode.AUTO, + OverkizCommandParam.MANU: HVACMode.HEAT_COOL, +} + +HVAC_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODES.items()} + +OVERKIZ_TO_HVAC_ACTION: dict[str, HVACAction] = { + OverkizCommandParam.COOLING: HVACAction.COOLING, + OverkizCommandParam.HEATING: HVACAction.HEATING, +} + +MAP_PRESET_TEMPERATURES: dict[str, str] = { + PRESET_COMFORT: OverkizState.CORE_COMFORT_ROOM_TEMPERATURE, + PRESET_ECO: OverkizState.CORE_ECO_ROOM_TEMPERATURE, + PRESET_AWAY: OverkizState.CORE_SECURED_POSITION_TEMPERATURE, +} + +SETPOINT_MODE_TO_OVERKIZ_COMMAND: dict[str, str] = { + OverkizCommandParam.COMFORT: OverkizCommand.SET_COMFORT_TEMPERATURE, + OverkizCommandParam.ECO: OverkizCommand.SET_ECO_TEMPERATURE, + OverkizCommandParam.SECURED: OverkizCommand.SET_SECURED_POSITION_TEMPERATURE, +} + +TEMPERATURE_SENSOR_DEVICE_INDEX = 2 + + +class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity): + """Representation of Somfy Heating Temperature Interface. + + The thermostat has 3 ways of working: + - Auto: Switch to eco/comfort temperature on a schedule (day/hour of the day) + - Manual comfort: The thermostat use the temperature of the comfort setting (19°C degree by default) + - Manual eco: The thermostat use the temperature of the eco setting (17°C by default) + - Freeze protection: The thermostat use the temperature of the freeze protection (7°C by default) + + There's also the possibility to change the working mode, this can be used to change from a heated + floor to a cooling floor in the summer. + """ + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] + _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] + # Both min and max temp values have been retrieved from the Somfy Application. + _attr_min_temp = 15.0 + _attr_max_temp = 26.0 + + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Init method.""" + super().__init__(device_url, coordinator) + self.temperature_device = self.executor.linked_device( + TEMPERATURE_SENSOR_DEVICE_INDEX + ) + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation i.e. heat, cool mode.""" + state = self.device.states[OverkizState.CORE_ON_OFF] + if state and state.value_as_str == OverkizCommandParam.OFF: + return HVACMode.OFF + + if ( + state := self.device.states[ + OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_ACTIVE_MODE + ] + ) and state.value_as_str: + return OVERKIZ_TO_HVAC_MODES[state.value_as_str] + + return HVACMode.OFF + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + await self.executor.async_execute_command( + OverkizCommand.SET_ACTIVE_MODE, HVAC_MODES_TO_OVERKIZ[hvac_mode] + ) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., home, away, temp.""" + if ( + state := self.device.states[ + OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_SETPOINT_MODE + ] + ) and state.value_as_str: + return OVERKIZ_TO_PRESET_MODES[state.value_as_str] + return None + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + await self.executor.async_execute_command( + OverkizCommand.SET_MANU_AND_SET_POINT_MODES, + PRESET_MODES_TO_OVERKIZ[preset_mode], + ) + + @property + def hvac_action(self) -> HVACAction | None: + """Return the current running hvac operation if supported.""" + if ( + current_operation := self.device.states[ + OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_OPERATING_MODE + ] + ) and current_operation.value_as_str: + return OVERKIZ_TO_HVAC_ACTION[current_operation.value_as_str] + + return None + + @property + def target_temperature(self) -> float | None: + """Return the target temperature.""" + + # Allow to get the current target temperature for the current preset + # The preset can be switched manually or on a schedule (auto). + # This allows to reflect the current target temperature automatically + if not self.preset_mode: + return None + + mode = PRESET_MODES_TO_OVERKIZ[self.preset_mode] + if mode not in MAP_PRESET_TEMPERATURES: + return None + + if state := self.device.states[MAP_PRESET_TEMPERATURES[mode]]: + return state.value_as_float + return None + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + if temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]: + return temperature.value_as_float + return None + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new temperature.""" + temperature = kwargs[ATTR_TEMPERATURE] + + if ( + mode := self.device.states[ + OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_SETPOINT_MODE + ] + ) and mode.value_as_str: + return await self.executor.async_execute_command( + SETPOINT_MODE_TO_OVERKIZ_COMMAND[mode.value_as_str], temperature + ) diff --git a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py index 7409b5307cf..4059f8521b8 100644 --- a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py +++ b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py @@ -33,10 +33,12 @@ OVERKIZ_TO_PRESET_MODES: dict[OverkizCommandParam, str] = { OverkizCommandParam.AT_HOME_MODE: PRESET_HOME, OverkizCommandParam.AWAY_MODE: PRESET_AWAY, OverkizCommandParam.FREEZE_MODE: PRESET_FREEZE, + OverkizCommandParam.GEOFENCING_MODE: PRESET_NONE, OverkizCommandParam.MANUAL_MODE: PRESET_NONE, OverkizCommandParam.SLEEPING_MODE: PRESET_NIGHT, OverkizCommandParam.SUDDEN_DROP_MODE: PRESET_NONE, } + PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODES.items()} TARGET_TEMP_TO_OVERKIZ = { PRESET_HOME: OverkizState.SOMFY_THERMOSTAT_AT_HOME_TARGET_TEMPERATURE, diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py index eac749f1bc0..4f3f50bf0e8 100644 --- a/homeassistant/components/overkiz/config_flow.py +++ b/homeassistant/components/overkiz/config_flow.py @@ -1,31 +1,46 @@ -"""Config flow for Overkiz (by Somfy) integration.""" +"""Config flow for Overkiz integration.""" from __future__ import annotations from collections.abc import Mapping from typing import Any, cast -from aiohttp import ClientError +from aiohttp import ClientConnectorCertificateError, ClientError from pyoverkiz.client import OverkizClient -from pyoverkiz.const import SUPPORTED_SERVERS +from pyoverkiz.const import SERVERS_WITH_LOCAL_API, SUPPORTED_SERVERS +from pyoverkiz.enums import APIType, Server from pyoverkiz.exceptions import ( BadCredentialsException, CozyTouchBadCredentialsException, MaintenanceException, + NotSuchTokenException, TooManyAttemptsBannedException, TooManyRequestsException, UnknownUserException, ) -from pyoverkiz.models import obfuscate_id +from pyoverkiz.models import OverkizServer +from pyoverkiz.obfuscate import obfuscate_id +from pyoverkiz.utils import generate_local_server, is_overkiz_gateway import voluptuous as vol from homeassistant import config_entries from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, + CONF_VERIFY_SSL, +) from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_create_clientsession -from .const import CONF_HUB, DEFAULT_HUB, DOMAIN, LOGGER +from .const import CONF_API_TYPE, CONF_HUB, DEFAULT_SERVER, DOMAIN, LOGGER + + +class DeveloperModeDisabled(HomeAssistantError): + """Error to indicate Somfy Developer Mode is disabled.""" class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -33,46 +48,103 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - _config_entry: ConfigEntry | None - _default_user: None | str - _default_hub: str + _reauth_entry: ConfigEntry | None = None + _api_type: APIType = APIType.CLOUD + _user: str | None = None + _server: str = DEFAULT_SERVER + _host: str = "gateway-xxxx-xxxx-xxxx.local:8443" - def __init__(self) -> None: - """Initialize Overkiz Config Flow.""" - super().__init__() - - self._config_entry = None - self._default_user = None - self._default_hub = DEFAULT_HUB - - async def async_validate_input(self, user_input: dict[str, Any]) -> None: + async def async_validate_input(self, user_input: dict[str, Any]) -> dict[str, Any]: """Validate user credentials.""" - username = user_input[CONF_USERNAME] - password = user_input[CONF_PASSWORD] - server = SUPPORTED_SERVERS[user_input[CONF_HUB]] - session = async_create_clientsession(self.hass) + user_input[CONF_API_TYPE] = self._api_type - client = OverkizClient( - username=username, password=password, server=server, session=session + client = self._create_cloud_client( + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + server=SUPPORTED_SERVERS[user_input[CONF_HUB]], ) - await client.login(register_event_listener=False) - # Set first gateway id as unique id + # For Local API, we create and activate a local token + if self._api_type == APIType.LOCAL: + user_input[CONF_TOKEN] = await self._create_local_api_token( + cloud_client=client, + host=user_input[CONF_HOST], + verify_ssl=user_input[CONF_VERIFY_SSL], + ) + + # Set main gateway id as unique id if gateways := await client.get_gateways(): - gateway_id = gateways[0].id - await self.async_set_unique_id(gateway_id) + for gateway in gateways: + if is_overkiz_gateway(gateway.id): + gateway_id = gateway.id + await self.async_set_unique_id(gateway_id) + + return user_input async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step via config flow.""" - errors = {} + if user_input: + self._server = user_input[CONF_HUB] + + # Some Overkiz hubs do support a local API + # Users can choose between local or cloud API. + if self._server in SERVERS_WITH_LOCAL_API: + return await self.async_step_local_or_cloud() + + return await self.async_step_cloud() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HUB, default=self._server): vol.In( + {key: hub.name for key, hub in SUPPORTED_SERVERS.items()} + ), + } + ), + ) + + async def async_step_local_or_cloud( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Users can choose between local API or cloud API via config flow.""" + if user_input: + self._api_type = user_input[CONF_API_TYPE] + + if self._api_type == APIType.LOCAL: + return await self.async_step_local() + + return await self.async_step_cloud() + + return self.async_show_form( + step_id="local_or_cloud", + data_schema=vol.Schema( + { + vol.Required(CONF_API_TYPE): vol.In( + { + APIType.LOCAL: "Local API", + APIType.CLOUD: "Cloud API", + } + ), + } + ), + ) + + async def async_step_cloud( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the cloud authentication step via config flow.""" + errors: dict[str, str] = {} description_placeholders = {} if user_input: - self._default_user = user_input[CONF_USERNAME] - self._default_hub = user_input[CONF_HUB] + self._user = user_input[CONF_USERNAME] + + # inherit the server from previous step + user_input[CONF_HUB] = self._server try: await self.async_validate_input(user_input) @@ -81,7 +153,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except BadCredentialsException as exception: # If authentication with CozyTouch auth server is valid, but token is invalid # for Overkiz API server, the hardware is not supported. - if user_input[CONF_HUB] == "atlantic_cozytouch" and not isinstance( + if user_input[CONF_HUB] == Server.ATLANTIC_COZYTOUCH and not isinstance( exception, CozyTouchBadCredentialsException ): description_placeholders["unsupported_device"] = "CozyTouch" @@ -99,26 +171,26 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # the Overkiz API server. Login will return unknown user. description_placeholders["unsupported_device"] = "Somfy Protect" errors["base"] = "unsupported_hardware" - except Exception as exception: # pylint: disable=broad-except + except Exception: # pylint: disable=broad-except errors["base"] = "unknown" - LOGGER.exception(exception) + LOGGER.exception("Unknown error") else: - if self._config_entry: - if self._config_entry.unique_id != self.unique_id: + if self._reauth_entry: + if self._reauth_entry.unique_id != self.unique_id: return self.async_abort(reason="reauth_wrong_account") # Update existing entry during reauth self.hass.config_entries.async_update_entry( - self._config_entry, + self._reauth_entry, data={ - **self._config_entry.data, + **self._reauth_entry.data, **user_input, }, ) self.hass.async_create_task( self.hass.config_entries.async_reload( - self._config_entry.entry_id + self._reauth_entry.entry_id ) ) @@ -132,14 +204,96 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_show_form( - step_id="user", + step_id="cloud", data_schema=vol.Schema( { - vol.Required(CONF_USERNAME, default=self._default_user): str, + vol.Required(CONF_USERNAME, default=self._user): str, vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_HUB, default=self._default_hub): vol.In( - {key: hub.name for key, hub in SUPPORTED_SERVERS.items()} - ), + } + ), + description_placeholders=description_placeholders, + errors=errors, + ) + + async def async_step_local( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the local authentication step via config flow.""" + errors = {} + description_placeholders = {} + + if user_input: + self._host = user_input[CONF_HOST] + self._user = user_input[CONF_USERNAME] + + # inherit the server from previous step + user_input[CONF_HUB] = self._server + + try: + user_input = await self.async_validate_input(user_input) + except TooManyRequestsException: + errors["base"] = "too_many_requests" + except BadCredentialsException: + errors["base"] = "invalid_auth" + except ClientConnectorCertificateError as exception: + errors["base"] = "certificate_verify_failed" + LOGGER.debug(exception) + except (TimeoutError, ClientError) as exception: + errors["base"] = "cannot_connect" + LOGGER.debug(exception) + except MaintenanceException: + errors["base"] = "server_in_maintenance" + except TooManyAttemptsBannedException: + errors["base"] = "too_many_attempts" + except NotSuchTokenException: + errors["base"] = "no_such_token" + except DeveloperModeDisabled: + errors["base"] = "developer_mode_disabled" + except UnknownUserException: + # Somfy Protect accounts are not supported since they don't use + # the Overkiz API server. Login will return unknown user. + description_placeholders["unsupported_device"] = "Somfy Protect" + errors["base"] = "unsupported_hardware" + except Exception: # pylint: disable=broad-except + errors["base"] = "unknown" + LOGGER.exception("Unknown error") + else: + if self._reauth_entry: + if self._reauth_entry.unique_id != self.unique_id: + return self.async_abort(reason="reauth_wrong_account") + + # Update existing entry during reauth + self.hass.config_entries.async_update_entry( + self._reauth_entry, + data={ + **self._reauth_entry.data, + **user_input, + }, + ) + + self.hass.async_create_task( + self.hass.config_entries.async_reload( + self._reauth_entry.entry_id + ) + ) + + return self.async_abort(reason="reauth_successful") + + # Create new entry + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=user_input[CONF_HOST], data=user_input + ) + + return self.async_show_form( + step_id="local", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=self._host): str, + vol.Required(CONF_USERNAME, default=self._user): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_VERIFY_SSL, default=True): bool, } ), description_placeholders=description_placeholders, @@ -150,6 +304,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle DHCP discovery.""" hostname = discovery_info.hostname gateway_id = hostname[8:22] + self._host = f"gateway-{gateway_id}.local:8443" LOGGER.debug("DHCP discovery detected gateway %s", obfuscate_id(gateway_id)) return await self._process_discovery(gateway_id) @@ -160,8 +315,22 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle ZeroConf discovery.""" properties = discovery_info.properties gateway_id = properties["gateway_pin"] + hostname = discovery_info.hostname + + LOGGER.debug( + "ZeroConf discovery detected gateway %s on %s (%s)", + obfuscate_id(gateway_id), + hostname, + discovery_info.type, + ) + + if discovery_info.type == "_kizbox._tcp.local.": + self._host = f"gateway-{gateway_id}.local:8443" + + if discovery_info.type == "_kizboxdev._tcp.local.": + self._host = f"{discovery_info.hostname[:-1]}:{discovery_info.port}" + self._api_type = APIType.LOCAL - LOGGER.debug("ZeroConf discovery detected gateway %s", obfuscate_id(gateway_id)) return await self._process_discovery(gateway_id) async def _process_discovery(self, gateway_id: str) -> FlowResult: @@ -174,16 +343,72 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle reauth.""" - self._config_entry = cast( + self._reauth_entry = cast( ConfigEntry, self.hass.config_entries.async_get_entry(self.context["entry_id"]), ) self.context["title_placeholders"] = { - "gateway_id": self._config_entry.unique_id + "gateway_id": self._reauth_entry.unique_id } - self._default_user = self._config_entry.data[CONF_USERNAME] - self._default_hub = self._config_entry.data[CONF_HUB] + self._user = self._reauth_entry.data[CONF_USERNAME] + self._server = self._reauth_entry.data[CONF_HUB] + self._api_type = self._reauth_entry.data[CONF_API_TYPE] + + if self._reauth_entry.data[CONF_API_TYPE] == APIType.LOCAL: + self._host = self._reauth_entry.data[CONF_HOST] return await self.async_step_user(dict(entry_data)) + + def _create_cloud_client( + self, username: str, password: str, server: OverkizServer + ) -> OverkizClient: + session = async_create_clientsession(self.hass) + client = OverkizClient( + username=username, password=password, server=server, session=session + ) + + return client + + async def _create_local_api_token( + self, cloud_client: OverkizClient, host: str, verify_ssl: bool + ) -> str: + """Create local API token.""" + # Create session on Somfy cloud server to generate an access token for local API + gateways = await cloud_client.get_gateways() + + gateway_id = "" + for gateway in gateways: + # Overkiz can return multiple gateways, but we only can generate a token + # for the main gateway. + if is_overkiz_gateway(gateway.id): + gateway_id = gateway.id + + developer_mode = await cloud_client.get_setup_option( + f"developerMode-{gateway_id}" + ) + + if developer_mode is None: + raise DeveloperModeDisabled + + token = await cloud_client.generate_local_token(gateway_id) + await cloud_client.activate_local_token( + gateway_id=gateway_id, token=token, label="Home Assistant/local" + ) + + session = async_create_clientsession(self.hass, verify_ssl=verify_ssl) + + # Local API + local_client = OverkizClient( + username="", + password="", + token=token, + session=session, + server=generate_local_server(host=host), + verify_ssl=verify_ssl, + ) + + await local_client.login() + + return token diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index 102d09a76b1..0f30f64444b 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -5,7 +5,13 @@ from datetime import timedelta import logging from typing import Final -from pyoverkiz.enums import MeasuredValueType, OverkizCommandParam, UIClass, UIWidget +from pyoverkiz.enums import ( + MeasuredValueType, + OverkizCommandParam, + Server, + UIClass, + UIWidget, +) from homeassistant.const import ( CONCENTRATION_PARTS_PER_BILLION, @@ -31,8 +37,10 @@ from homeassistant.const import ( DOMAIN: Final = "overkiz" LOGGER: logging.Logger = logging.getLogger(__package__) +CONF_API_TYPE: Final = "api_type" CONF_HUB: Final = "hub" -DEFAULT_HUB: Final = "somfy_europe" +DEFAULT_SERVER: Final = Server.SOMFY_EUROPE +DEFAULT_HOST: Final = "gateway-xxxx-xxxx-xxxx.local:8443" UPDATE_INTERVAL: Final = timedelta(seconds=30) UPDATE_INTERVAL_ALL_ASSUMED_STATE: Final = timedelta(minutes=60) @@ -91,6 +99,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = { UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.DOMESTIC_HOT_WATER_PRODUCTION: Platform.WATER_HEATER, # widgetName, uiClass is WaterHeatingSystem (not supported) UIWidget.DOMESTIC_HOT_WATER_TANK: Platform.SWITCH, # widgetName, uiClass is WaterHeatingSystem (not supported) + UIWidget.HITACHI_AIR_TO_AIR_HEAT_PUMP: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.HITACHI_DHW: Platform.WATER_HEATER, # widgetName, uiClass is HitachiHeatingSystem (not supported) UIWidget.MY_FOX_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported) UIWidget.MY_FOX_SECURITY_CAMERA: Platform.SWITCH, # widgetName, uiClass is Camera (not supported) @@ -98,6 +107,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = { UIWidget.RTD_OUTDOOR_SIREN: Platform.SWITCH, # widgetName, uiClass is Siren (not supported) UIWidget.RTS_GENERIC: Platform.COVER, # widgetName, uiClass is Generic (not supported) UIWidget.SIREN_STATUS: None, # widgetName, uiClass is Siren (siren) + UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.SOMFY_THERMOSTAT: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.STATELESS_ALARM_CONTROLLER: Platform.SWITCH, # widgetName, uiClass is Alarm (not supported) UIWidget.STATEFUL_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported) diff --git a/homeassistant/components/overkiz/coordinator.py b/homeassistant/components/overkiz/coordinator.py index 7c9cab5f181..4630af8bbf8 100644 --- a/homeassistant/components/overkiz/coordinator.py +++ b/homeassistant/components/overkiz/coordinator.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging from typing import Any -from aiohttp import ServerDisconnectedError +from aiohttp import ClientConnectorError, ServerDisconnectedError from pyoverkiz.client import OverkizClient from pyoverkiz.enums import EventName, ExecutionState, Protocol from pyoverkiz.exceptions import ( @@ -43,7 +43,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): name: str, client: OverkizClient, devices: list[Device], - places: Place, + places: Place | None, update_interval: timedelta | None = None, config_entry_id: str, ) -> None: @@ -79,7 +79,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): raise UpdateFailed("Server is down for maintenance.") from exception except InvalidEventListenerIdException as exception: raise UpdateFailed(exception) from exception - except TimeoutError as exception: + except (TimeoutError, ClientConnectorError) as exception: raise UpdateFailed("Failed to connect.") from exception except (ServerDisconnectedError, NotAuthenticatedException): self.executions = {} diff --git a/homeassistant/components/overkiz/cover_entities/generic_cover.py b/homeassistant/components/overkiz/cover_entities/generic_cover.py index 06f257d416b..f4a8a6a0d45 100644 --- a/homeassistant/components/overkiz/cover_entities/generic_cover.py +++ b/homeassistant/components/overkiz/cover_entities/generic_cover.py @@ -1,7 +1,6 @@ """Base class for Overkiz covers, shutters, awnings, etc.""" from __future__ import annotations -from collections.abc import Mapping from typing import Any, cast from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState @@ -28,12 +27,18 @@ COMMANDS_OPEN: list[OverkizCommand] = [ OverkizCommand.OPEN, OverkizCommand.UP, ] -COMMANDS_OPEN_TILT: list[OverkizCommand] = [OverkizCommand.OPEN_SLATS] +COMMANDS_OPEN_TILT: list[OverkizCommand] = [ + OverkizCommand.OPEN_SLATS, + OverkizCommand.TILT_DOWN, +] COMMANDS_CLOSE: list[OverkizCommand] = [ OverkizCommand.CLOSE, OverkizCommand.DOWN, ] -COMMANDS_CLOSE_TILT: list[OverkizCommand] = [OverkizCommand.CLOSE_SLATS] +COMMANDS_CLOSE_TILT: list[OverkizCommand] = [ + OverkizCommand.CLOSE_SLATS, + OverkizCommand.TILT_UP, +] COMMANDS_SET_TILT_POSITION: list[OverkizCommand] = [OverkizCommand.SET_ORIENTATION] @@ -115,17 +120,6 @@ class OverkizGenericCover(OverkizEntity, CoverEntity): for execution in self.coordinator.executions.values() ) - @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: - """Return the device state attributes.""" - attr = super().extra_state_attributes or {} - - # Obstruction Detected attribute is used by HomeKit - if self.executor.has_state(OverkizState.IO_PRIORITY_LOCK_LEVEL): - return {**attr, **{ATTR_OBSTRUCTION_DETECTED: True}} - - return attr - @property def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" diff --git a/homeassistant/components/overkiz/diagnostics.py b/homeassistant/components/overkiz/diagnostics.py index 77ca0227579..cb8cf6eb22f 100644 --- a/homeassistant/components/overkiz/diagnostics.py +++ b/homeassistant/components/overkiz/diagnostics.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from pyoverkiz.enums import APIType from pyoverkiz.obfuscate import obfuscate_id from homeassistant.config_entries import ConfigEntry @@ -10,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry from . import HomeAssistantOverkizData -from .const import CONF_HUB, DOMAIN +from .const import CONF_API_TYPE, CONF_HUB, DOMAIN async def async_get_config_entry_diagnostics( @@ -23,11 +24,16 @@ async def async_get_config_entry_diagnostics( data = { "setup": await client.get_diagnostic_data(), "server": entry.data[CONF_HUB], - "execution_history": [ - repr(execution) for execution in await client.get_execution_history() - ], + "api_type": entry.data.get(CONF_API_TYPE, APIType.CLOUD), } + # Only Overkiz cloud servers expose an endpoint with execution history + if client.api_type == APIType.CLOUD: + execution_history = [ + repr(execution) for execution in await client.get_execution_history() + ] + data["execution_history"] = execution_history + return data @@ -49,11 +55,15 @@ async def async_get_device_diagnostics( }, "setup": await client.get_diagnostic_data(), "server": entry.data[CONF_HUB], - "execution_history": [ + "api_type": entry.data.get(CONF_API_TYPE, APIType.CLOUD), + } + + # Only Overkiz cloud servers expose an endpoint with execution history + if client.api_type == APIType.CLOUD: + data["execution_history"] = [ repr(execution) for execution in await client.get_execution_history() if any(command.device_url == device_url for command in execution.commands) - ], - } + ] return data diff --git a/homeassistant/components/overkiz/executor.py b/homeassistant/components/overkiz/executor.py index 9095ec8d38e..af29dbaf523 100644 --- a/homeassistant/components/overkiz/executor.py +++ b/homeassistant/components/overkiz/executor.py @@ -5,9 +5,12 @@ from typing import Any, cast from urllib.parse import urlparse from pyoverkiz.enums import OverkizCommand, Protocol +from pyoverkiz.exceptions import OverkizException from pyoverkiz.models import Command, Device, StateDefinition from pyoverkiz.types import StateType as OverkizStateType +from homeassistant.exceptions import HomeAssistantError + from .coordinator import OverkizDataUpdateCoordinator # Commands that don't support setting @@ -88,11 +91,15 @@ class OverkizExecutor: ): parameters.append(0) - exec_id = await self.coordinator.client.execute_command( - self.device.device_url, - Command(command_name, parameters), - "Home Assistant", - ) + try: + exec_id = await self.coordinator.client.execute_command( + self.device.device_url, + Command(command_name, parameters), + "Home Assistant", + ) + # Catch Overkiz exceptions to support `continue_on_error` functionality + except OverkizException as exception: + raise HomeAssistantError(exception) from exception # ExecutionRegisteredEvent doesn't contain the device_url, thus we need to register it here self.coordinator.executions[exec_id] = { diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index cc9a410392a..e5c1665b2e4 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -11,13 +11,17 @@ ], "documentation": "https://www.home-assistant.io/integrations/overkiz", "integration_type": "hub", - "iot_class": "cloud_polling", + "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.13.2"], + "requirements": ["pyoverkiz==1.13.3"], "zeroconf": [ { "type": "_kizbox._tcp.local.", "name": "gateway*" + }, + { + "type": "_kizboxdev._tcp.local.", + "name": "gateway*" } ] } diff --git a/homeassistant/components/overkiz/select.py b/homeassistant/components/overkiz/select.py index 155fc3a538f..5f72ca23a80 100644 --- a/homeassistant/components/overkiz/select.py +++ b/homeassistant/components/overkiz/select.py @@ -42,6 +42,19 @@ def _select_option_open_closed_pedestrian( ) +def _select_option_open_closed_partial( + option: str, execute_command: Callable[..., Awaitable[None]] +) -> Awaitable[None]: + """Change the selected option for Open/Closed/Partial.""" + return execute_command( + { + OverkizCommandParam.CLOSED: OverkizCommand.CLOSE, + OverkizCommandParam.OPEN: OverkizCommand.OPEN, + OverkizCommandParam.PARTIAL: OverkizCommand.PARTIAL_POSITION, + }[OverkizCommandParam(option)] + ) + + def _select_option_memorized_simple_volume( option: str, execute_command: Callable[..., Awaitable[None]] ) -> Awaitable[None]: @@ -73,6 +86,18 @@ SELECT_DESCRIPTIONS: list[OverkizSelectDescription] = [ select_option=_select_option_open_closed_pedestrian, translation_key="open_closed_pedestrian", ), + OverkizSelectDescription( + key=OverkizState.CORE_OPEN_CLOSED_PARTIAL, + name="Position", + icon="mdi:content-save-cog", + options=[ + OverkizCommandParam.OPEN, + OverkizCommandParam.PARTIAL, + OverkizCommandParam.CLOSED, + ], + select_option=_select_option_open_closed_partial, + translation_key="open_closed_partial", + ), OverkizSelectDescription( key=OverkizState.IO_MEMORIZED_SIMPLE_VOLUME, name="Memorized simple volume", diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index f56643e8cd4..a267b54b398 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -100,7 +100,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ name="Water volume estimation at 40 °C", icon="mdi:water", native_unit_of_measurement=UnitOfVolume.LITERS, - device_class=SensorDeviceClass.VOLUME, + device_class=SensorDeviceClass.VOLUME_STORAGE, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, ), @@ -110,7 +110,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ icon="mdi:water", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.IO_OUTLET_ENGINE, @@ -413,6 +413,22 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ options=["open", "tilt", "closed"], translation_key="three_way_handle_direction", ), + # Hitachi air to air heatpump outdoor temperature sensors (HLRRWIFI protocol) + OverkizSensorDescription( + key=OverkizState.HLRRWIFI_OUTDOOR_TEMPERATURE, + name="Outdoor temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + # Hitachi air to air heatpump outdoor temperature sensors (OVP protocol) + OverkizSensorDescription( + key=OverkizState.OVP_OUTDOOR_TEMPERATURE, + name="Outdoor temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), ] SUPPORTED_STATES = {description.key: description for description in SENSOR_DESCRIPTIONS} @@ -465,7 +481,12 @@ class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity): """Return the value of the sensor.""" state = self.device.states.get(self.entity_description.key) - if not state or not state.value: + if ( + state is None + or state.value is None + or self.state_class != SensorStateClass.MEASUREMENT + and not state.value + ): return None # Transform the value with a lambda function diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index bcf1e121f6f..2a549f1c24d 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -3,18 +3,40 @@ "flow_title": "Gateway: {gateway_id}", "step": { "user": { - "description": "The Overkiz platform is used by various vendors like Somfy (Connexoon / TaHoma), Hitachi (Hi Kumo), Rexel (Energeasy Connect) and Atlantic (Cozytouch). Enter your application credentials and select your hub.", + "description": "Select your server. The Overkiz platform is used by various vendors like Somfy (Connexoon / TaHoma), Hitachi (Hi Kumo) and Atlantic (Cozytouch).", + "data": { + "hub": "Server" + } + }, + "local_or_cloud": { + "description": "Choose between local or cloud API. Local API supports TaHoma Connexoon, TaHoma v2, and TaHoma Switch. Climate devices are not supported in local API.", + "data": { + "api_type": "API type" + } + }, + "cloud": { + "description": "Enter your application credentials.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "local": { + "description": "By activating the [Developer Mode of your TaHoma box](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started), you can authorize third-party software (like Home Assistant) to connect to it via your local network. \n\n After activation, enter your application credentials and change the host to include your gateway-pin or enter the IP address of your gateway.", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "hub": "Hub" + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "certificate_verify_failed": "Cannot connect to host, certificate verify failed.", + "developer_mode_disabled": "Developer Mode disabled. Activate the Developer Mode of your Somfy TaHoma box first.", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "no_such_token": "Cannot create a token for this gateway. Please confirm if the account is linked to this gateway.", "server_in_maintenance": "Server is down for maintenance", "too_many_attempts": "Too many attempts with an invalid token, temporarily banned", "too_many_requests": "Too many requests, try again later", @@ -64,6 +86,13 @@ "closed": "[%key:common::state::closed%]" } }, + "open_closed_partial": { + "state": { + "open": "[%key:common::state::open%]", + "partial": "Partial", + "closed": "[%key:common::state::closed%]" + } + }, "memorized_simple_volume": { "state": { "highest": "Highest", diff --git a/homeassistant/components/p1_monitor/manifest.json b/homeassistant/components/p1_monitor/manifest.json index 3ed5589e577..0dfe1f3a46c 100644 --- a/homeassistant/components/p1_monitor/manifest.json +++ b/homeassistant/components/p1_monitor/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["p1monitor"], "quality_scale": "platinum", - "requirements": ["p1monitor==2.1.1"] + "requirements": ["p1monitor==3.0.0"] } diff --git a/homeassistant/components/peco/__init__.py b/homeassistant/components/peco/__init__.py index ad74200dace..bcdc4195100 100644 --- a/homeassistant/components/peco/__init__.py +++ b/homeassistant/components/peco/__init__.py @@ -5,7 +5,14 @@ from dataclasses import dataclass from datetime import timedelta from typing import Final -from peco import AlertResults, BadJSONError, HttpError, OutageResults, PecoOutageApi +from peco import ( + AlertResults, + BadJSONError, + HttpError, + OutageResults, + PecoOutageApi, + UnresponsiveMeterError, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -13,9 +20,16 @@ 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 CONF_COUNTY, DOMAIN, LOGGER, SCAN_INTERVAL +from .const import ( + CONF_COUNTY, + CONF_PHONE_NUMBER, + DOMAIN, + LOGGER, + OUTAGE_SCAN_INTERVAL, + SMART_METER_SCAN_INTERVAL, +) -PLATFORMS: Final = [Platform.SENSOR] +PLATFORMS: Final = [Platform.SENSOR, Platform.BINARY_SENSOR] @dataclass @@ -31,9 +45,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: websession = async_get_clientsession(hass) api = PecoOutageApi() + # Outage Counter Setup county: str = entry.data[CONF_COUNTY] - async def async_update_data() -> PECOCoordinatorData: + async def async_update_outage_data() -> OutageResults: """Fetch data from API.""" try: outages: OutageResults = ( @@ -53,15 +68,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, LOGGER, name="PECO Outage Count", - update_method=async_update_data, - update_interval=timedelta(minutes=SCAN_INTERVAL), + update_method=async_update_outage_data, + update_interval=timedelta(minutes=OUTAGE_SCAN_INTERVAL), ) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {"outage_count": coordinator} + if phone_number := entry.data.get(CONF_PHONE_NUMBER): + # Smart Meter Setup] + + async def async_update_meter_data() -> bool: + """Fetch data from API.""" + try: + data: bool = await api.meter_check(phone_number, websession) + except UnresponsiveMeterError as err: + raise UpdateFailed("Unresponsive meter") from err + except HttpError as err: + raise UpdateFailed(f"Error fetching data: {err}") from err + except BadJSONError as err: + raise UpdateFailed(f"Error parsing data: {err}") from err + return data + + coordinator = DataUpdateCoordinator( + hass, + LOGGER, + name="PECO Smart Meter", + update_method=async_update_meter_data, + update_interval=timedelta(minutes=SMART_METER_SCAN_INTERVAL), + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id]["smart_meter"] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/peco/binary_sensor.py b/homeassistant/components/peco/binary_sensor.py new file mode 100644 index 00000000000..7f0402b207f --- /dev/null +++ b/homeassistant/components/peco/binary_sensor.py @@ -0,0 +1,59 @@ +"""Binary sensor for PECO outage counter.""" +from __future__ import annotations + +from typing import Final + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN + +PARALLEL_UPDATES: Final = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary sensor for PECO.""" + if "smart_meter" not in hass.data[DOMAIN][config_entry.entry_id]: + return + coordinator: DataUpdateCoordinator[bool] = hass.data[DOMAIN][config_entry.entry_id][ + "smart_meter" + ] + + async_add_entities( + [PecoBinarySensor(coordinator, phone_number=config_entry.data["phone_number"])] + ) + + +class PecoBinarySensor( + CoordinatorEntity[DataUpdateCoordinator[bool]], BinarySensorEntity +): + """Binary sensor for PECO outage counter.""" + + _attr_icon = "mdi:gauge" + _attr_device_class = BinarySensorDeviceClass.POWER + _attr_name = "Meter Status" + + def __init__( + self, coordinator: DataUpdateCoordinator[bool], phone_number: str + ) -> None: + """Initialize binary sensor for PECO.""" + super().__init__(coordinator) + self._attr_unique_id = f"{phone_number}" + + @property + def is_on(self) -> bool: + """Return if the meter has power.""" + return self.coordinator.data diff --git a/homeassistant/components/peco/config_flow.py b/homeassistant/components/peco/config_flow.py index 63ca7f3291a..261cdb031bf 100644 --- a/homeassistant/components/peco/config_flow.py +++ b/homeassistant/components/peco/config_flow.py @@ -1,41 +1,122 @@ """Config flow for PECO Outage Counter integration.""" from __future__ import annotations +import logging from typing import Any +from peco import ( + HttpError, + IncompatibleMeterError, + PecoOutageApi, + UnresponsiveMeterError, +) import voluptuous as vol from homeassistant import config_entries from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv -from .const import CONF_COUNTY, COUNTY_LIST, DOMAIN +from .const import CONF_COUNTY, CONF_PHONE_NUMBER, COUNTY_LIST, DOMAIN STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_COUNTY): vol.In(COUNTY_LIST), + vol.Optional(CONF_PHONE_NUMBER): cv.string, } ) +_LOGGER = logging.getLogger(__name__) + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for PECO Outage Counter.""" VERSION = 1 + meter_verification: bool = False + meter_data: dict[str, str] = {} + meter_error: dict[str, str] = {} + + async def _verify_meter(self, phone_number: str) -> None: + """Verify if the meter is compatible.""" + + api = PecoOutageApi() + + try: + await api.meter_check(phone_number) + except ValueError: + self.meter_error = {"phone_number": "invalid_phone_number", "type": "error"} + except IncompatibleMeterError: + self.meter_error = {"phone_number": "incompatible_meter", "type": "abort"} + except UnresponsiveMeterError: + self.meter_error = {"phone_number": "unresponsive_meter", "type": "error"} + except HttpError: + self.meter_error = {"phone_number": "http_error", "type": "error"} + + self.hass.async_create_task( + self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" + if self.meter_verification is True: + return self.async_show_progress_done(next_step_id="finish_smart_meter") + if user_input is None: return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, ) county = user_input[CONF_COUNTY] - await self.async_set_unique_id(county) + if CONF_PHONE_NUMBER not in user_input: + await self.async_set_unique_id(county) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=f"{user_input[CONF_COUNTY].capitalize()} Outage Count", + data=user_input, + ) + + phone_number = user_input[CONF_PHONE_NUMBER] + + await self.async_set_unique_id(f"{county}-{phone_number}") self._abort_if_unique_id_configured() - return self.async_create_entry( - title=f"{county.capitalize()} Outage Count", data=user_input + self.meter_verification = True + + if self.meter_error is not None: + # Clear any previous errors, since the user may have corrected them + self.meter_error = {} + + self.hass.async_create_task(self._verify_meter(phone_number)) + + self.meter_data = user_input + + return self.async_show_progress( + step_id="user", + progress_action="verifying_meter", + ) + + async def async_step_finish_smart_meter( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the finish smart meter step.""" + if "phone_number" in self.meter_error: + if self.meter_error["type"] == "error": + self.meter_verification = False + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors={"phone_number": self.meter_error["phone_number"]}, + ) + + return self.async_abort(reason=self.meter_error["phone_number"]) + + return self.async_create_entry( + title=f"{self.meter_data[CONF_COUNTY].capitalize()} - {self.meter_data[CONF_PHONE_NUMBER]}", + data=self.meter_data, ) diff --git a/homeassistant/components/peco/const.py b/homeassistant/components/peco/const.py index b0198ac8761..1df8ae41ecb 100644 --- a/homeassistant/components/peco/const.py +++ b/homeassistant/components/peco/const.py @@ -14,6 +14,8 @@ COUNTY_LIST: Final = [ "TOTAL", ] CONFIG_FLOW_COUNTIES: Final = [{county: county.capitalize()} for county in COUNTY_LIST] -SCAN_INTERVAL: Final = 9 +OUTAGE_SCAN_INTERVAL: Final = 9 # minutes +SMART_METER_SCAN_INTERVAL: Final = 15 # minutes CONF_COUNTY: Final = "county" ATTR_CONTENT: Final = "content" +CONF_PHONE_NUMBER: Final = "phone_number" diff --git a/homeassistant/components/peco/sensor.py b/homeassistant/components/peco/sensor.py index 5be41f7c7e1..935f2b659f9 100644 --- a/homeassistant/components/peco/sensor.py +++ b/homeassistant/components/peco/sensor.py @@ -91,7 +91,7 @@ async def async_setup_entry( ) -> None: """Set up the sensor platform.""" county: str = config_entry.data[CONF_COUNTY] - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id]["outage_count"] async_add_entities( PecoSensor(sensor, county, coordinator) for sensor in SENSOR_LIST diff --git a/homeassistant/components/peco/strings.json b/homeassistant/components/peco/strings.json index 059b2ba71a7..cdf5bb497db 100644 --- a/homeassistant/components/peco/strings.json +++ b/homeassistant/components/peco/strings.json @@ -3,12 +3,26 @@ "step": { "user": { "data": { - "county": "County" + "county": "County", + "phone_number": "Phone Number" + }, + "data_description": { + "county": "County used for outage number retrieval", + "phone_number": "Phone number associated with the PECO account (optional). Adding a phone number adds a binary sensor confirming if your power is out or not, and not an issue with a breaker or an issue on your end." } } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "incompatible_meter": "Your meter is not compatible with smart meter checking." + }, + "progress": { + "verifying_meter": "One moment. Verifying that your meter is compatible. This may take a minute or two." + }, + "error": { + "invalid_phone_number": "Please enter a valid phone number.", + "unresponsive_meter": "Your meter is not responding. Please try again later.", + "http_error": "There was an error communicating with PECO. The issue that is most likely is that you entered an invalid phone number. Please check the phone number or try again later." } }, "entity": { diff --git a/homeassistant/components/permobil/__init__.py b/homeassistant/components/permobil/__init__.py new file mode 100644 index 00000000000..2f3c4c04c50 --- /dev/null +++ b/homeassistant/components/permobil/__init__.py @@ -0,0 +1,63 @@ +"""The MyPermobil integration.""" +from __future__ import annotations + +import logging + +from mypermobil import MyPermobil, MyPermobilClientException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_CODE, + CONF_EMAIL, + CONF_REGION, + CONF_TOKEN, + CONF_TTL, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed + +from .const import APPLICATION, DOMAIN +from .coordinator import MyPermobilCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up MyPermobil from a config entry.""" + + # create the API object from the config and save it in hass + session = hass.helpers.aiohttp_client.async_get_clientsession() + p_api = MyPermobil( + application=APPLICATION, + session=session, + email=entry.data[CONF_EMAIL], + region=entry.data[CONF_REGION], + code=entry.data[CONF_CODE], + token=entry.data[CONF_TOKEN], + expiration_date=entry.data[CONF_TTL], + ) + try: + p_api.self_authenticate() + except MyPermobilClientException as err: + _LOGGER.error("Error authenticating %s", err) + raise ConfigEntryAuthFailed(f"Config error for {p_api.email}") from err + + # create the coordinator with the API object + coordinator = MyPermobilCoordinator(hass, p_api) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/permobil/config_flow.py b/homeassistant/components/permobil/config_flow.py new file mode 100644 index 00000000000..644ea29d8a3 --- /dev/null +++ b/homeassistant/components/permobil/config_flow.py @@ -0,0 +1,173 @@ +"""Config flow for MyPermobil integration.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from mypermobil import MyPermobil, MyPermobilAPIException, MyPermobilClientException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_REGION, CONF_TOKEN, CONF_TTL +from homeassistant.core import HomeAssistant, async_get_hass +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import APPLICATION, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +GET_EMAIL_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig(type=TextSelectorType.EMAIL) + ), + } +) + +GET_TOKEN_SCHEMA = vol.Schema({vol.Required(CONF_CODE): cv.string}) + + +class PermobilConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Permobil config flow.""" + + VERSION = 1 + region_names: dict[str, str] = {} + data: dict[str, str] = {} + + def __init__(self) -> None: + """Initialize flow.""" + hass: HomeAssistant = async_get_hass() + session = async_get_clientsession(hass) + self.p_api = MyPermobil(APPLICATION, session=session) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Invoke when a user initiates a flow via the user interface.""" + errors: dict[str, str] = {} + + if user_input: + try: + self.p_api.set_email(user_input[CONF_EMAIL]) + except MyPermobilClientException: + _LOGGER.exception("Error validating email") + errors["base"] = "invalid_email" + + self.data.update(user_input) + + await self.async_set_unique_id(self.data[CONF_EMAIL]) + self._abort_if_unique_id_configured() + + if errors or not user_input: + return self.async_show_form( + step_id="user", data_schema=GET_EMAIL_SCHEMA, errors=errors + ) + return await self.async_step_region() + + async def async_step_region( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Invoke when a user initiates a flow via the user interface.""" + errors: dict[str, str] = {} + if not user_input: + # fetch the list of regions names and urls from the api + # for the user to select from. + try: + self.region_names = await self.p_api.request_region_names() + _LOGGER.debug( + "region names %s", + ",".join(list(self.region_names.keys())), + ) + except MyPermobilAPIException: + _LOGGER.exception("Error requesting regions") + errors["base"] = "region_fetch_error" + + else: + region_url = self.region_names[user_input[CONF_REGION]] + + self.data[CONF_REGION] = region_url + self.p_api.set_region(region_url) + _LOGGER.debug("region %s", self.p_api.region) + try: + # tell backend to send code to the users email + await self.p_api.request_application_code() + except MyPermobilAPIException: + _LOGGER.exception("Error requesting code") + errors["base"] = "code_request_error" + + if errors or not user_input: + # the error could either be that the fetch region did not pass + # or that the request application code failed + schema = vol.Schema( + { + vol.Required(CONF_REGION): selector.SelectSelector( + selector.SelectSelectorConfig( + options=list(self.region_names.keys()), + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ), + } + ) + return self.async_show_form( + step_id="region", data_schema=schema, errors=errors + ) + + return await self.async_step_email_code() + + async def async_step_email_code( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Second step in config flow to enter the email code.""" + errors: dict[str, str] = {} + + if user_input: + try: + self.p_api.set_code(user_input[CONF_CODE]) + self.data.update(user_input) + token, ttl = await self.p_api.request_application_token() + self.data[CONF_TOKEN] = token + self.data[CONF_TTL] = ttl + except (MyPermobilAPIException, MyPermobilClientException): + # the code did not pass validation by the api client + # or the backend returned an error when trying to validate the code + _LOGGER.exception("Error verifying code") + errors["base"] = "invalid_code" + + if errors or not user_input: + return self.async_show_form( + step_id="email_code", data_schema=GET_TOKEN_SCHEMA, errors=errors + ) + + return self.async_create_entry(title=self.data[CONF_EMAIL], data=self.data) + + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + assert reauth_entry + + try: + email: str = reauth_entry.data[CONF_EMAIL] + region: str = reauth_entry.data[CONF_REGION] + self.p_api.set_email(email) + self.p_api.set_region(region) + self.data = { + CONF_EMAIL: email, + CONF_REGION: region, + } + await self.p_api.request_application_code() + except MyPermobilAPIException: + _LOGGER.exception("Error requesting code for reauth") + return self.async_abort(reason="unknown") + + return await self.async_step_email_code() diff --git a/homeassistant/components/permobil/const.py b/homeassistant/components/permobil/const.py new file mode 100644 index 00000000000..fd5fe673f2a --- /dev/null +++ b/homeassistant/components/permobil/const.py @@ -0,0 +1,11 @@ +"""Constants for the MyPermobil integration.""" + +DOMAIN = "permobil" + +APPLICATION = "Home Assistant" + + +BATTERY_ASSUMED_VOLTAGE = 25.0 # This is the average voltage over all states of charge +REGIONS = "regions" +KM = "kilometers" +MILES = "miles" diff --git a/homeassistant/components/permobil/coordinator.py b/homeassistant/components/permobil/coordinator.py new file mode 100644 index 00000000000..3695236cdf0 --- /dev/null +++ b/homeassistant/components/permobil/coordinator.py @@ -0,0 +1,57 @@ +"""DataUpdateCoordinator for permobil integration.""" + +import asyncio +from dataclasses import dataclass +from datetime import timedelta +import logging + +from mypermobil import MyPermobil, MyPermobilAPIException + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class MyPermobilData: + """MyPermobil data stored in the DataUpdateCoordinator.""" + + battery: dict[str, str | float | int | list | dict] + daily_usage: dict[str, str | float | int | list | dict] + records: dict[str, str | float | int | list | dict] + + +class MyPermobilCoordinator(DataUpdateCoordinator[MyPermobilData]): + """MyPermobil coordinator.""" + + def __init__(self, hass: HomeAssistant, p_api: MyPermobil) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + name="permobil", + update_interval=timedelta(minutes=5), + ) + self.p_api = p_api + + async def _async_update_data(self) -> MyPermobilData: + """Fetch data from the 3 API endpoints.""" + try: + async with asyncio.timeout(10): + battery = await self.p_api.get_battery_info() + daily_usage = await self.p_api.get_daily_usage() + records = await self.p_api.get_usage_records() + return MyPermobilData( + battery=battery, + daily_usage=daily_usage, + records=records, + ) + + except MyPermobilAPIException as err: + _LOGGER.exception( + "Error fetching data from MyPermobil API for account %s %s", + self.p_api.email, + err, + ) + raise UpdateFailed from err diff --git a/homeassistant/components/permobil/manifest.json b/homeassistant/components/permobil/manifest.json new file mode 100644 index 00000000000..fd937fc6f8a --- /dev/null +++ b/homeassistant/components/permobil/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "permobil", + "name": "MyPermobil", + "codeowners": ["@IsakNyberg"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/permobil", + "iot_class": "cloud_polling", + "requirements": ["mypermobil==0.1.6"] +} diff --git a/homeassistant/components/permobil/sensor.py b/homeassistant/components/permobil/sensor.py new file mode 100644 index 00000000000..e942aa265b8 --- /dev/null +++ b/homeassistant/components/permobil/sensor.py @@ -0,0 +1,222 @@ +"""Platform for sensor integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Any + +from mypermobil import ( + BATTERY_AMPERE_HOURS_LEFT, + BATTERY_CHARGE_TIME_LEFT, + BATTERY_DISTANCE_LEFT, + BATTERY_INDOOR_DRIVE_TIME, + BATTERY_MAX_AMPERE_HOURS, + BATTERY_MAX_DISTANCE_LEFT, + BATTERY_STATE_OF_CHARGE, + BATTERY_STATE_OF_HEALTH, + RECORDS_SEATING, + USAGE_ADJUSTMENTS, + USAGE_DISTANCE, +) + +from homeassistant import config_entries +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfLength, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import BATTERY_ASSUMED_VOLTAGE, DOMAIN +from .coordinator import MyPermobilCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class PermobilRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Any], float | int] + available_fn: Callable[[Any], bool] + + +@dataclass +class PermobilSensorEntityDescription( + SensorEntityDescription, PermobilRequiredKeysMixin +): + """Describes Permobil sensor entity.""" + + +SENSOR_DESCRIPTIONS: tuple[PermobilSensorEntityDescription, ...] = ( + PermobilSensorEntityDescription( + # Current battery as a percentage + value_fn=lambda data: data.battery[BATTERY_STATE_OF_CHARGE[0]], + available_fn=lambda data: BATTERY_STATE_OF_CHARGE[0] in data.battery, + key="state_of_charge", + translation_key="state_of_charge", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + ), + PermobilSensorEntityDescription( + # Current battery health as a percentage of original capacity + value_fn=lambda data: data.battery[BATTERY_STATE_OF_HEALTH[0]], + available_fn=lambda data: BATTERY_STATE_OF_HEALTH[0] in data.battery, + key="state_of_health", + translation_key="state_of_health", + icon="mdi:battery-heart-variant", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + PermobilSensorEntityDescription( + # Time until fully charged (displays 0 if not charging) + value_fn=lambda data: data.battery[BATTERY_CHARGE_TIME_LEFT[0]], + available_fn=lambda data: BATTERY_CHARGE_TIME_LEFT[0] in data.battery, + key="charge_time_left", + translation_key="charge_time_left", + icon="mdi:battery-clock", + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + ), + PermobilSensorEntityDescription( + # Distance possible on current change (km) + value_fn=lambda data: data.battery[BATTERY_DISTANCE_LEFT[0]], + available_fn=lambda data: BATTERY_DISTANCE_LEFT[0] in data.battery, + key="distance_left", + translation_key="distance_left", + icon="mdi:map-marker-distance", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + ), + PermobilSensorEntityDescription( + # Drive time possible on current charge + value_fn=lambda data: data.battery[BATTERY_INDOOR_DRIVE_TIME[0]], + available_fn=lambda data: BATTERY_INDOOR_DRIVE_TIME[0] in data.battery, + key="indoor_drive_time", + translation_key="indoor_drive_time", + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + ), + PermobilSensorEntityDescription( + # Watt hours the battery can store given battery health + value_fn=lambda data: data.battery[BATTERY_MAX_AMPERE_HOURS[0]] + * BATTERY_ASSUMED_VOLTAGE, + available_fn=lambda data: BATTERY_MAX_AMPERE_HOURS[0] in data.battery, + key="max_watt_hours", + translation_key="max_watt_hours", + icon="mdi:lightning-bolt", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY_STORAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + PermobilSensorEntityDescription( + # Current amount of watt hours in battery + value_fn=lambda data: data.battery[BATTERY_AMPERE_HOURS_LEFT[0]] + * BATTERY_ASSUMED_VOLTAGE, + available_fn=lambda data: BATTERY_AMPERE_HOURS_LEFT[0] in data.battery, + key="watt_hours_left", + translation_key="watt_hours_left", + icon="mdi:lightning-bolt", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY_STORAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + PermobilSensorEntityDescription( + # Distance that can be traveled with full charge given battery health (km) + value_fn=lambda data: data.battery[BATTERY_MAX_DISTANCE_LEFT[0]], + available_fn=lambda data: BATTERY_MAX_DISTANCE_LEFT[0] in data.battery, + key="max_distance_left", + translation_key="max_distance_left", + icon="mdi:map-marker-distance", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + ), + PermobilSensorEntityDescription( + # Distance traveled today monotonically increasing, resets every 24h (km) + value_fn=lambda data: data.daily_usage[USAGE_DISTANCE[0]], + available_fn=lambda data: USAGE_DISTANCE[0] in data.daily_usage, + key="usage_distance", + translation_key="usage_distance", + icon="mdi:map-marker-distance", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + PermobilSensorEntityDescription( + # Number of adjustments monotonically increasing, resets every 24h + value_fn=lambda data: data.daily_usage[USAGE_ADJUSTMENTS[0]], + available_fn=lambda data: USAGE_ADJUSTMENTS[0] in data.daily_usage, + key="usage_adjustments", + translation_key="usage_adjustments", + icon="mdi:seat-recline-extra", + native_unit_of_measurement="adjustments", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + PermobilSensorEntityDescription( + # Largest number of adjustemnts in a single 24h period, never resets + value_fn=lambda data: data.records[RECORDS_SEATING[0]], + available_fn=lambda data: RECORDS_SEATING[0] in data.records, + key="record_adjustments", + translation_key="record_adjustments", + icon="mdi:seat-recline-extra", + native_unit_of_measurement="adjustments", + state_class=SensorStateClass.TOTAL_INCREASING, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Create sensors from a config entry created in the integrations UI.""" + + coordinator: MyPermobilCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + PermobilSensor(coordinator=coordinator, description=description) + for description in SENSOR_DESCRIPTIONS + ) + + +class PermobilSensor(CoordinatorEntity[MyPermobilCoordinator], SensorEntity): + """Representation of a Sensor. + + This implements the common functions of all sensors. + """ + + _attr_has_entity_name = True + _attr_suggested_display_precision = 0 + entity_description: PermobilSensorEntityDescription + _available = True + + def __init__( + self, + coordinator: MyPermobilCoordinator, + description: PermobilSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator=coordinator) + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.p_api.email}_{self.entity_description.key}" + ) + + @property + def available(self) -> bool: + """Return True if the sensor has value.""" + return super().available and self.entity_description.available_fn( + self.coordinator.data + ) + + @property + def native_value(self) -> float | int: + """Return the value of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/permobil/strings.json b/homeassistant/components/permobil/strings.json new file mode 100644 index 00000000000..b0b630eff08 --- /dev/null +++ b/homeassistant/components/permobil/strings.json @@ -0,0 +1,70 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "Enter your permobil email" + } + }, + "email_code": { + "description": "Enter the code that was sent to your email.", + "data": { + "code": "Email code" + } + }, + "region": { + "description": "Select the region of your account.", + "data": { + "code": "Region" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "unknown": "Unexpected error, more information in the logs", + "region_fetch_error": "Error fetching regions", + "code_request_error": "Error requesting application code", + "invalid_email": "Invalid email", + "invalid_code": "The code you gave is incorrect" + } + }, + "entity": { + "sensor": { + "state_of_charge": { + "name": "Battery charge" + }, + "state_of_health": { + "name": "Battery health" + }, + "charge_time_left": { + "name": "Charge time left" + }, + "distance_left": { + "name": "Distance left" + }, + "indoor_drive_time": { + "name": "Indoor drive time" + }, + "max_watt_hours": { + "name": "Battery max watt hours" + }, + "watt_hours_left": { + "name": "Watt hours left" + }, + "max_distance_left": { + "name": "Full charge distance" + }, + "usage_distance": { + "name": "Distance traveled" + }, + "usage_adjustments": { + "name": "Number of adjustments" + }, + "record_adjustments": { + "name": "Record number of adjustments" + } + } + } +} diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 49b719a5490..b6f8b5b2db6 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -1,9 +1,12 @@ """Support for tracking people.""" from __future__ import annotations +from http import HTTPStatus +from ipaddress import ip_address import logging from typing import Any +from aiohttp import web import voluptuous as vol from homeassistant.auth import EVENT_USER_REMOVED @@ -13,6 +16,7 @@ from homeassistant.components.device_tracker import ( DOMAIN as DEVICE_TRACKER_DOMAIN, SourceType, ) +from homeassistant.components.http.view import HomeAssistantView from homeassistant.const import ( ATTR_EDITABLE, ATTR_ENTITY_ID, @@ -47,10 +51,12 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.network import is_cloud_connection from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass +from homeassistant.util.network import is_local _LOGGER = logging.getLogger(__name__) @@ -385,6 +391,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, DOMAIN, SERVICE_RELOAD, async_reload_yaml ) + hass.http.register_view(ListPersonsView) + return True @@ -569,3 +577,44 @@ def _get_latest(prev: State | None, curr: State): if prev is None or curr.last_updated > prev.last_updated: return curr return prev + + +class ListPersonsView(HomeAssistantView): + """List all persons if request is made from a local network.""" + + requires_auth = False + url = "/api/person/list" + name = "api:person:list" + + async def get(self, request: web.Request) -> web.Response: + """Return a list of persons if request comes from a local IP.""" + try: + remote_address = ip_address(request.remote) # type: ignore[arg-type] + except ValueError: + return self.json_message( + message="Invalid remote IP", + status_code=HTTPStatus.BAD_REQUEST, + message_code="invalid_remote_ip", + ) + + hass: HomeAssistant = request.app["hass"] + if is_cloud_connection(hass) or not is_local(remote_address): + return self.json_message( + message="Not local", + status_code=HTTPStatus.BAD_REQUEST, + message_code="not_local", + ) + + yaml, storage, _ = hass.data[DOMAIN] + persons = [*yaml.async_items(), *storage.async_items()] + + return self.json( + { + person[ATTR_USER_ID]: { + ATTR_NAME: person[ATTR_NAME], + CONF_PICTURE: person.get(CONF_PICTURE), + } + for person in persons + if person.get(ATTR_USER_ID) + } + ) diff --git a/homeassistant/components/person/manifest.json b/homeassistant/components/person/manifest.json index f6682058dae..7f370be6fbe 100644 --- a/homeassistant/components/person/manifest.json +++ b/homeassistant/components/person/manifest.json @@ -3,7 +3,7 @@ "name": "Person", "after_dependencies": ["device_tracker"], "codeowners": [], - "dependencies": ["image_upload"], + "dependencies": ["image_upload", "http"], "documentation": "https://www.home-assistant.io/integrations/person", "integration_type": "system", "iot_class": "calculated", diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index 969c6c7b837..b81fec90a59 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -36,6 +36,7 @@ PLATFORMS = [ Platform.LIGHT, Platform.REMOTE, Platform.SWITCH, + Platform.BINARY_SENSOR, ] LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/philips_js/binary_sensor.py b/homeassistant/components/philips_js/binary_sensor.py new file mode 100644 index 00000000000..1e6c1241aea --- /dev/null +++ b/homeassistant/components/philips_js/binary_sensor.py @@ -0,0 +1,107 @@ +"""Philips TV binary sensors.""" +from __future__ import annotations + +from dataclasses import dataclass + +from haphilipsjs import PhilipsTV + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import PhilipsTVDataUpdateCoordinator +from .const import DOMAIN +from .entity import PhilipsJsEntity + + +@dataclass +class PhilipsTVBinarySensorEntityDescription(BinarySensorEntityDescription): + """A entity description for Philips TV binary sensor.""" + + def __init__(self, recording_value, *args, **kwargs) -> None: + """Set up a binary sensor entity description and add additional attributes.""" + super().__init__(*args, **kwargs) + self.recording_value: str = recording_value + + +DESCRIPTIONS = ( + PhilipsTVBinarySensorEntityDescription( + key="recording_ongoing", + translation_key="recording_ongoing", + icon="mdi:record-rec", + recording_value="RECORDING_ONGOING", + ), + PhilipsTVBinarySensorEntityDescription( + key="recording_new", + translation_key="recording_new", + icon="mdi:new-box", + recording_value="RECORDING_NEW", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the configuration entry.""" + coordinator: PhilipsTVDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] + + if ( + coordinator.api.json_feature_supported("recordings", "List") + and coordinator.api.api_version == 6 + ): + async_add_entities( + PhilipsTVBinarySensorEntityRecordingType(coordinator, description) + for description in DESCRIPTIONS + ) + + +def _check_for_recording_entry(api: PhilipsTV, entry: str, value: str) -> bool: + """Return True if at least one specified value is available within entry of list.""" + if api.recordings_list is None: + return False + for rec in api.recordings_list["recordings"]: + if rec.get(entry) == value: + return True + return False + + +class PhilipsTVBinarySensorEntityRecordingType(PhilipsJsEntity, BinarySensorEntity): + """A Philips TV binary sensor class, which allows multiple entities given by a BinarySensorEntityDescription.""" + + entity_description: PhilipsTVBinarySensorEntityDescription + + def __init__( + self, + coordinator: PhilipsTVDataUpdateCoordinator, + description: PhilipsTVBinarySensorEntityDescription, + ) -> None: + """Initialize entity class.""" + self.entity_description = description + self._attr_unique_id = f"{coordinator.unique_id}_{description.key}" + self._attr_device_info = coordinator.device_info + self._attr_is_on = _check_for_recording_entry( + coordinator.api, + "RecordingType", + description.recording_value, + ) + + super().__init__(coordinator) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator and set is_on true if one specified value is available within given entry of list.""" + self._attr_is_on = _check_for_recording_entry( + self.coordinator.api, + "RecordingType", + self.entity_description.recording_value, + ) + super()._handle_coordinator_update() diff --git a/homeassistant/components/philips_js/strings.json b/homeassistant/components/philips_js/strings.json index 6c738a36df3..3ea632ce436 100644 --- a/homeassistant/components/philips_js/strings.json +++ b/homeassistant/components/philips_js/strings.json @@ -44,6 +44,14 @@ } }, "entity": { + "binary_sensor": { + "recording_new": { + "name": "New recording available" + }, + "recording_ongoing": { + "name": "Recording ongoing" + } + }, "light": { "ambilight": { "name": "Ambilight" diff --git a/homeassistant/components/picnic/__init__.py b/homeassistant/components/picnic/__init__.py index ec7f6e15425..6826d8940ab 100644 --- a/homeassistant/components/picnic/__init__.py +++ b/homeassistant/components/picnic/__init__.py @@ -10,7 +10,7 @@ from .const import CONF_API, CONF_COORDINATOR, CONF_COUNTRY_CODE, DOMAIN from .coordinator import PicnicUpdateCoordinator from .services import async_register_services -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.SENSOR, Platform.TODO] def create_picnic_client(entry: ConfigEntry): diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index e7a69e0bf02..507ab82e8e2 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -17,10 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from .const import ( @@ -44,6 +41,7 @@ from .const import ( SENSOR_SELECTED_SLOT_MIN_ORDER_VALUE, SENSOR_SELECTED_SLOT_START, ) +from .coordinator import PicnicUpdateCoordinator @dataclass @@ -237,7 +235,7 @@ async def async_setup_entry( ) -class PicnicSensor(SensorEntity, CoordinatorEntity): +class PicnicSensor(SensorEntity, CoordinatorEntity[PicnicUpdateCoordinator]): """The CoordinatorEntity subclass representing Picnic sensors.""" _attr_has_entity_name = True @@ -246,7 +244,7 @@ class PicnicSensor(SensorEntity, CoordinatorEntity): def __init__( self, - coordinator: DataUpdateCoordinator[Any], + coordinator: PicnicUpdateCoordinator, config_entry: ConfigEntry, description: PicnicSensorEntityDescription, ) -> None: diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py index 3af2a521f8a..b44d4dd5a62 100644 --- a/homeassistant/components/picnic/services.py +++ b/homeassistant/components/picnic/services.py @@ -66,7 +66,7 @@ async def handle_add_product( product_id = call.data.get("product_id") if not product_id: product_id = await hass.async_add_executor_job( - _product_search, api_client, cast(str, call.data["product_name"]) + product_search, api_client, cast(str, call.data["product_name"]) ) if not product_id: @@ -77,8 +77,11 @@ async def handle_add_product( ) -def _product_search(api_client: PicnicAPI, product_name: str) -> None | str: +def product_search(api_client: PicnicAPI, product_name: str | None) -> None | str: """Query the api client for the product name.""" + if product_name is None: + return None + search_result = api_client.search(product_name) if not search_result or "items" not in search_result[0]: diff --git a/homeassistant/components/picnic/strings.json b/homeassistant/components/picnic/strings.json index 0fd107609d1..9a6b7162fd5 100644 --- a/homeassistant/components/picnic/strings.json +++ b/homeassistant/components/picnic/strings.json @@ -21,6 +21,11 @@ } }, "entity": { + "todo": { + "shopping_cart": { + "name": "Shopping cart" + } + }, "sensor": { "cart_items_count": { "name": "Cart items count" diff --git a/homeassistant/components/picnic/todo.py b/homeassistant/components/picnic/todo.py new file mode 100644 index 00000000000..fea99f7403d --- /dev/null +++ b/homeassistant/components/picnic/todo.py @@ -0,0 +1,95 @@ +"""Definition of Picnic shopping cart.""" +from __future__ import annotations + +import logging +from typing import cast + +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_COORDINATOR, DOMAIN +from .coordinator import PicnicUpdateCoordinator +from .services import product_search + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Picnic shopping cart todo platform config entry.""" + picnic_coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_COORDINATOR] + + async_add_entities([PicnicCart(picnic_coordinator, config_entry)]) + + +class PicnicCart(TodoListEntity, CoordinatorEntity[PicnicUpdateCoordinator]): + """A Picnic Shopping Cart TodoListEntity.""" + + _attr_has_entity_name = True + _attr_icon = "mdi:cart" + _attr_supported_features = TodoListEntityFeature.CREATE_TODO_ITEM + _attr_translation_key = "shopping_cart" + + def __init__( + self, + coordinator: PicnicUpdateCoordinator, + config_entry: ConfigEntry, + ) -> None: + """Initialize PicnicCart.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, cast(str, config_entry.unique_id))}, + manufacturer="Picnic", + model=config_entry.unique_id, + ) + self._attr_unique_id = f"{config_entry.unique_id}-cart" + + @property + def todo_items(self) -> list[TodoItem] | None: + """Get the current set of items in cart items.""" + if self.coordinator.data is None: + return None + + _LOGGER.debug(self.coordinator.data["cart_data"]["items"]) + + items = [] + for item in self.coordinator.data["cart_data"]["items"]: + for article in item["items"]: + items.append( + TodoItem( + summary=f"{article['name']} ({article['unit_quantity']})", + uid=f"{item['id']}-{article['id']}", + status=TodoItemStatus.NEEDS_ACTION, # We set 'NEEDS_ACTION' so they count as state + ) + ) + + return items + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Add item to shopping cart.""" + product_id = await self.hass.async_add_executor_job( + product_search, self.coordinator.picnic_api_client, item.summary + ) + + if not product_id: + raise ServiceValidationError("No product found or no product ID given") + + await self.hass.async_add_executor_job( + self.coordinator.picnic_api_client.add_product, product_id, 1 + ) + + await self.coordinator.async_refresh() diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py index 26dd8113231..81df1401f91 100644 --- a/homeassistant/components/ping/__init__.py +++ b/homeassistant/components/ping/__init__.py @@ -4,18 +4,22 @@ from __future__ import annotations from dataclasses import dataclass import logging -from icmplib import SocketPermissionError, ping as icmp_ping +from icmplib import SocketPermissionError, async_ping +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, PLATFORMS +from .const import CONF_PING_COUNT, DOMAIN +from .coordinator import PingUpdateCoordinator +from .helpers import PingDataICMPLib, PingDataSubProcess _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.platform_only_config_schema(DOMAIN) +PLATFORMS = [Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER] @dataclass(slots=True) @@ -23,26 +27,68 @@ class PingDomainData: """Dataclass to store privileged status.""" privileged: bool | None + coordinators: dict[str, PingUpdateCoordinator] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the ping integration.""" - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) hass.data[DOMAIN] = PingDomainData( - privileged=await hass.async_add_executor_job(_can_use_icmp_lib_with_privilege), + privileged=await _can_use_icmp_lib_with_privilege(), + coordinators={}, ) return True -def _can_use_icmp_lib_with_privilege() -> None | bool: +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Ping (ICMP) from a config entry.""" + + data: PingDomainData = hass.data[DOMAIN] + + host: str = entry.options[CONF_HOST] + count: int = int(entry.options[CONF_PING_COUNT]) + ping_cls: type[PingDataICMPLib | PingDataSubProcess] + if data.privileged is None: + ping_cls = PingDataSubProcess + else: + ping_cls = PingDataICMPLib + + coordinator = PingUpdateCoordinator( + hass=hass, ping=ping_cls(hass, host, count, data.privileged) + ) + await coordinator.async_config_entry_first_refresh() + + data.coordinators[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + + return True + + +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle an options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + # drop coordinator for config entry + hass.data[DOMAIN].coordinators.pop(entry.entry_id) + + return unload_ok + + +async def _can_use_icmp_lib_with_privilege() -> None | bool: """Verify we can create a raw socket.""" try: - icmp_ping("127.0.0.1", count=0, timeout=0, privileged=True) + await async_ping("127.0.0.1", count=0, timeout=0, privileged=True) except SocketPermissionError: try: - icmp_ping("127.0.0.1", count=0, timeout=0, privileged=False) + await async_ping("127.0.0.1", count=0, timeout=0, privileged=False) except SocketPermissionError: _LOGGER.debug( "Cannot use icmplib because privileges are insufficient to create the" diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index b120c453195..97636111586 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -1,7 +1,6 @@ """Tracks the latency of a host by sending ICMP echo requests (ping).""" from __future__ import annotations -from datetime import timedelta import logging from typing import Any @@ -12,34 +11,26 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.const import CONF_HOST, CONF_NAME, STATE_ON -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import PingDomainData -from .const import DOMAIN -from .helpers import PingDataICMPLib, PingDataSubProcess +from .const import CONF_IMPORTED_BY, CONF_PING_COUNT, DEFAULT_PING_COUNT, DOMAIN +from .coordinator import PingUpdateCoordinator _LOGGER = logging.getLogger(__name__) - ATTR_ROUND_TRIP_TIME_AVG = "round_trip_time_avg" ATTR_ROUND_TRIP_TIME_MAX = "round_trip_time_max" ATTR_ROUND_TRIP_TIME_MDEV = "round_trip_time_mdev" ATTR_ROUND_TRIP_TIME_MIN = "round_trip_time_min" -CONF_PING_COUNT = "count" - -DEFAULT_NAME = "Ping" -DEFAULT_PING_COUNT = 5 - -SCAN_INTERVAL = timedelta(minutes=5) - -PARALLEL_UPDATES = 50 - PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, @@ -57,75 +48,76 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Ping Binary sensor.""" + """YAML init: import via config flow.""" - data: PingDomainData = hass.data[DOMAIN] + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_IMPORTED_BY: "binary_sensor", **config}, + ) + ) - host: str = config[CONF_HOST] - count: int = config[CONF_PING_COUNT] - name: str = config.get(CONF_NAME, f"{DEFAULT_NAME} {host}") - privileged: bool | None = data.privileged - ping_cls: type[PingDataSubProcess | PingDataICMPLib] - if privileged is None: - ping_cls = PingDataSubProcess - else: - ping_cls = PingDataICMPLib - - async_add_entities( - [PingBinarySensor(name, ping_cls(hass, host, count, privileged))] + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Ping", + }, ) -class PingBinarySensor(RestoreEntity, BinarySensorEntity): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up a Ping config entry.""" + + data: PingDomainData = hass.data[DOMAIN] + + async_add_entities([PingBinarySensor(entry, data.coordinators[entry.entry_id])]) + + +class PingBinarySensor(CoordinatorEntity[PingUpdateCoordinator], BinarySensorEntity): """Representation of a Ping Binary sensor.""" _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + _attr_available = False - def __init__(self, name: str, ping: PingDataSubProcess | PingDataICMPLib) -> None: + def __init__( + self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator + ) -> None: """Initialize the Ping Binary sensor.""" - self._attr_available = False - self._attr_name = name - self._ping = ping + super().__init__(coordinator) + + self._attr_name = config_entry.title + self._attr_unique_id = config_entry.entry_id + + # if this was imported just enable it when it was enabled before + if CONF_IMPORTED_BY in config_entry.data: + self._attr_entity_registry_enabled_default = bool( + config_entry.data[CONF_IMPORTED_BY] == "binary_sensor" + ) @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self._ping.is_alive + return self.coordinator.data.is_alive @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes of the ICMP checo request.""" - if self._ping.data is None: + if self.coordinator.data.data is None: return None return { - ATTR_ROUND_TRIP_TIME_AVG: self._ping.data["avg"], - ATTR_ROUND_TRIP_TIME_MAX: self._ping.data["max"], - ATTR_ROUND_TRIP_TIME_MDEV: self._ping.data["mdev"], - ATTR_ROUND_TRIP_TIME_MIN: self._ping.data["min"], - } - - async def async_update(self) -> None: - """Get the latest data.""" - await self._ping.async_update() - self._attr_available = True - - async def async_added_to_hass(self) -> None: - """Restore previous state on restart to avoid blocking startup.""" - await super().async_added_to_hass() - - last_state = await self.async_get_last_state() - if last_state is not None: - self._attr_available = True - - if last_state is None or last_state.state != STATE_ON: - self._ping.data = None - return - - attributes = last_state.attributes - self._ping.is_alive = True - self._ping.data = { - "min": attributes[ATTR_ROUND_TRIP_TIME_MIN], - "max": attributes[ATTR_ROUND_TRIP_TIME_MAX], - "avg": attributes[ATTR_ROUND_TRIP_TIME_AVG], - "mdev": attributes[ATTR_ROUND_TRIP_TIME_MDEV], + ATTR_ROUND_TRIP_TIME_AVG: self.coordinator.data.data["avg"], + ATTR_ROUND_TRIP_TIME_MAX: self.coordinator.data.data["max"], + ATTR_ROUND_TRIP_TIME_MDEV: self.coordinator.data.data["mdev"], + ATTR_ROUND_TRIP_TIME_MIN: self.coordinator.data.data["min"], } diff --git a/homeassistant/components/ping/config_flow.py b/homeassistant/components/ping/config_flow.py new file mode 100644 index 00000000000..42cdd3f3a77 --- /dev/null +++ b/homeassistant/components/ping/config_flow.py @@ -0,0 +1,107 @@ +"""Config flow for Ping (ICMP) integration.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector +from homeassistant.util.network import is_ip_address + +from .const import CONF_IMPORTED_BY, CONF_PING_COUNT, DEFAULT_PING_COUNT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Ping.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + } + ), + ) + + if not is_ip_address(user_input[CONF_HOST]): + self.async_abort(reason="invalid_ip_address") + + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + return self.async_create_entry( + title=user_input[CONF_HOST], + data={}, + options={**user_input, CONF_PING_COUNT: DEFAULT_PING_COUNT}, + ) + + async def async_step_import(self, import_info: Mapping[str, Any]) -> FlowResult: + """Import an entry.""" + + to_import = { + CONF_HOST: import_info[CONF_HOST], + CONF_PING_COUNT: import_info[CONF_PING_COUNT], + } + title = import_info.get(CONF_NAME, import_info[CONF_HOST]) + + self._async_abort_entries_match({CONF_HOST: to_import[CONF_HOST]}) + return self.async_create_entry( + title=title, + data={CONF_IMPORTED_BY: import_info[CONF_IMPORTED_BY]}, + options=to_import, + ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Create the options flow.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle an options flow for Ping.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_HOST, default=self.config_entry.options[CONF_HOST] + ): str, + vol.Optional( + CONF_PING_COUNT, + default=self.config_entry.options[CONF_PING_COUNT], + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, max=100, mode=selector.NumberSelectorMode.BOX + ) + ), + } + ), + ) diff --git a/homeassistant/components/ping/const.py b/homeassistant/components/ping/const.py index fd70a9340c2..6ee53ea3d22 100644 --- a/homeassistant/components/ping/const.py +++ b/homeassistant/components/ping/const.py @@ -1,6 +1,5 @@ """Tracks devices by sending a ICMP echo request (ping).""" -from homeassistant.const import Platform # The ping binary and icmplib timeouts are not the same # timeout. ping is an overall timeout, icmplib is the @@ -15,4 +14,7 @@ ICMP_TIMEOUT = 1 PING_ATTEMPTS_COUNT = 3 DOMAIN = "ping" -PLATFORMS = [Platform.BINARY_SENSOR] + +CONF_PING_COUNT = "count" +CONF_IMPORTED_BY = "imported_by" +DEFAULT_PING_COUNT = 5 diff --git a/homeassistant/components/ping/coordinator.py b/homeassistant/components/ping/coordinator.py new file mode 100644 index 00000000000..dadd105b606 --- /dev/null +++ b/homeassistant/components/ping/coordinator.py @@ -0,0 +1,53 @@ +"""DataUpdateCoordinator for the ping integration.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Any + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .helpers import PingDataICMPLib, PingDataSubProcess + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(slots=True, frozen=True) +class PingResult: + """Dataclass returned by the coordinator.""" + + ip_address: str + is_alive: bool + data: dict[str, Any] | None + + +class PingUpdateCoordinator(DataUpdateCoordinator[PingResult]): + """The Ping update coordinator.""" + + ping: PingDataSubProcess | PingDataICMPLib + + def __init__( + self, + hass: HomeAssistant, + ping: PingDataSubProcess | PingDataICMPLib, + ) -> None: + """Initialize the Ping coordinator.""" + self.ping = ping + + super().__init__( + hass, + _LOGGER, + name=f"Ping {ping.ip_address}", + update_interval=timedelta(minutes=5), + ) + + async def _async_update_data(self) -> PingResult: + """Trigger ping check.""" + await self.ping.async_update() + return PingResult( + ip_address=self.ping.ip_address, + is_alive=self.ping.is_alive, + data=self.ping.data, + ) diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index 9a63a2f844d..417659aad5c 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -1,39 +1,43 @@ """Tracks devices by sending a ICMP echo request (ping).""" from __future__ import annotations -import asyncio -from datetime import datetime, timedelta import logging -import subprocess +from typing import Any -from icmplib import async_multiping import voluptuous as vol from homeassistant.components.device_tracker import ( - CONF_SCAN_INTERVAL, PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, - SCAN_INTERVAL, AsyncSeeCallback, + ScannerEntity, SourceType, ) -from homeassistant.const import CONF_HOSTS -from homeassistant.core import HomeAssistant +from homeassistant.components.device_tracker.legacy import ( + YAML_DEVICES, + remove_device_from_config, +) +from homeassistant.config import load_yaml_config_file +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_HOSTS, + CONF_NAME, + EVENT_HOMEASSISTANT_STARTED, +) +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, Event, HomeAssistant +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import dt as dt_util -from homeassistant.util.async_ import gather_with_limited_concurrency -from homeassistant.util.process import kill_subprocess +from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import PingDomainData -from .const import DOMAIN, ICMP_TIMEOUT, PING_ATTEMPTS_COUNT, PING_TIMEOUT +from .const import CONF_IMPORTED_BY, CONF_PING_COUNT, DOMAIN +from .coordinator import PingUpdateCoordinator _LOGGER = logging.getLogger(__name__) -PARALLEL_UPDATES = 0 -CONF_PING_COUNT = "count" -CONCURRENT_PING_LIMIT = 6 - PLATFORM_SCHEMA = BASE_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOSTS): {cv.slug: cv.string}, @@ -42,123 +46,123 @@ PLATFORM_SCHEMA = BASE_PLATFORM_SCHEMA.extend( ) -class HostSubProcess: - """Host object with ping detection.""" - - def __init__( - self, - ip_address: str, - dev_id: str, - hass: HomeAssistant, - config: ConfigType, - privileged: bool | None, - ) -> None: - """Initialize the Host pinger.""" - self.hass = hass - self.ip_address = ip_address - self.dev_id = dev_id - self._count = config[CONF_PING_COUNT] - self._ping_cmd = ["ping", "-n", "-q", "-c1", "-W1", ip_address] - - def ping(self) -> bool | None: - """Send an ICMP echo request and return True if success.""" - with subprocess.Popen( - self._ping_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - close_fds=False, # required for posix_spawn - ) as pinger: - try: - pinger.communicate(timeout=1 + PING_TIMEOUT) - return pinger.returncode == 0 - except subprocess.TimeoutExpired: - kill_subprocess(pinger) - return False - - except subprocess.CalledProcessError: - return False - - def update(self) -> bool: - """Update device state by sending one or more ping messages.""" - failed = 0 - while failed < self._count: # check more times if host is unreachable - if self.ping(): - return True - failed += 1 - - _LOGGER.debug("No response from %s failed=%d", self.ip_address, failed) - return False - - async def async_setup_scanner( hass: HomeAssistant, config: ConfigType, async_see: AsyncSeeCallback, discovery_info: DiscoveryInfoType | None = None, ) -> bool: - """Set up the Host objects and return the update function.""" + """Legacy init: import via config flow.""" + + async def _run_import(_: Event) -> None: + """Delete devices from known_device.yaml and import them via config flow.""" + _LOGGER.debug( + "Home Assistant successfully started, importing ping device tracker config entries now" + ) + + devices: dict[str, dict[str, Any]] = {} + try: + devices = await hass.async_add_executor_job( + load_yaml_config_file, hass.config.path(YAML_DEVICES) + ) + except (FileNotFoundError, HomeAssistantError): + _LOGGER.debug( + "No valid known_devices.yaml found, " + "skip removal of devices from known_devices.yaml" + ) + + for dev_name, dev_host in config[CONF_HOSTS].items(): + if dev_name in devices: + await hass.async_add_executor_job( + remove_device_from_config, hass, dev_name + ) + _LOGGER.debug("Removed device %s from known_devices.yaml", dev_name) + + if not hass.states.async_available(f"device_tracker.{dev_name}"): + hass.states.async_remove(f"device_tracker.{dev_name}") + + # run import after everything has been cleaned up + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_IMPORTED_BY: "device_tracker", + CONF_NAME: dev_name, + CONF_HOST: dev_host, + CONF_PING_COUNT: config[CONF_PING_COUNT], + }, + ) + ) + + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Ping", + }, + ) + + # delay the import until after Home Assistant has started and everything has been initialized, + # as the legacy device tracker entities will be restored after the legacy device tracker platforms + # have been set up, so we can only remove the entities from the state machine then + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _run_import) + + return True + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up a Ping config entry.""" data: PingDomainData = hass.data[DOMAIN] - privileged = data.privileged - ip_to_dev_id = {ip: dev_id for (dev_id, ip) in config[CONF_HOSTS].items()} - interval = config.get( - CONF_SCAN_INTERVAL, - timedelta(seconds=len(ip_to_dev_id) * config[CONF_PING_COUNT]) + SCAN_INTERVAL, - ) - _LOGGER.debug( - "Started ping tracker with interval=%s on hosts: %s", - interval, - ",".join(ip_to_dev_id.keys()), - ) + async_add_entities([PingDeviceTracker(entry, data.coordinators[entry.entry_id])]) - if privileged is None: - hosts = [ - HostSubProcess(ip, dev_id, hass, config, privileged) - for (dev_id, ip) in config[CONF_HOSTS].items() - ] - async def async_update(now: datetime) -> None: - """Update all the hosts on every interval time.""" - results = await gather_with_limited_concurrency( - CONCURRENT_PING_LIMIT, - *(hass.async_add_executor_job(host.update) for host in hosts), - ) - await asyncio.gather( - *( - async_see(dev_id=host.dev_id, source_type=SourceType.ROUTER) - for idx, host in enumerate(hosts) - if results[idx] - ) - ) +class PingDeviceTracker(CoordinatorEntity[PingUpdateCoordinator], ScannerEntity): + """Representation of a Ping device tracker.""" - else: + def __init__( + self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator + ) -> None: + """Initialize the Ping device tracker.""" + super().__init__(coordinator) - async def async_update(now: datetime) -> None: - """Update all the hosts on every interval time.""" - responses = await async_multiping( - list(ip_to_dev_id), - count=PING_ATTEMPTS_COUNT, - timeout=ICMP_TIMEOUT, - privileged=privileged, - ) - _LOGGER.debug("Multiping responses: %s", responses) - await asyncio.gather( - *( - async_see(dev_id=dev_id, source_type=SourceType.ROUTER) - for idx, dev_id in enumerate(ip_to_dev_id.values()) - if responses[idx].is_alive - ) - ) + self._attr_name = config_entry.title + self.config_entry = config_entry - async def _async_update_interval(now: datetime) -> None: - try: - await async_update(now) - finally: - if not hass.is_stopping: - async_track_point_in_utc_time( - hass, _async_update_interval, now + interval - ) + @property + def ip_address(self) -> str: + """Return the primary ip address of the device.""" + return self.coordinator.data.ip_address - await _async_update_interval(dt_util.now()) - return True + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self.config_entry.entry_id + + @property + def source_type(self) -> SourceType: + """Return the source type which is router.""" + return SourceType.ROUTER + + @property + def is_connected(self) -> bool: + """Return true if ping returns is_alive.""" + return self.coordinator.data.is_alive + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if entity is enabled by default.""" + if CONF_IMPORTED_BY in self.config_entry.data: + return bool(self.config_entry.data[CONF_IMPORTED_BY] == "device_tracker") + return False diff --git a/homeassistant/components/ping/helpers.py b/homeassistant/components/ping/helpers.py index da58858a801..ce3d5c3b461 100644 --- a/homeassistant/components/ping/helpers.py +++ b/homeassistant/components/ping/helpers.py @@ -33,7 +33,7 @@ class PingData: def __init__(self, hass: HomeAssistant, host: str, count: int) -> None: """Initialize the data object.""" self.hass = hass - self._ip_address = host + self.ip_address = host self._count = count @@ -49,10 +49,10 @@ class PingDataICMPLib(PingData): async def async_update(self) -> None: """Retrieve the latest details from the host.""" - _LOGGER.debug("ping address: %s", self._ip_address) + _LOGGER.debug("ping address: %s", self.ip_address) try: data = await async_ping( - self._ip_address, + self.ip_address, count=self._count, timeout=ICMP_TIMEOUT, privileged=self._privileged, @@ -89,7 +89,7 @@ class PingDataSubProcess(PingData): "-c", str(self._count), "-W1", - self._ip_address, + self.ip_address, ] async def async_ping(self) -> dict[str, Any] | None: diff --git a/homeassistant/components/ping/manifest.json b/homeassistant/components/ping/manifest.json index e27c3a239d0..ded5a3fd3e6 100644 --- a/homeassistant/components/ping/manifest.json +++ b/homeassistant/components/ping/manifest.json @@ -2,6 +2,7 @@ "domain": "ping", "name": "Ping (ICMP)", "codeowners": ["@jpbede"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ping", "iot_class": "local_polling", "loggers": ["icmplib"], diff --git a/homeassistant/components/ping/services.yaml b/homeassistant/components/ping/services.yaml deleted file mode 100644 index c983a105c93..00000000000 --- a/homeassistant/components/ping/services.yaml +++ /dev/null @@ -1 +0,0 @@ -reload: diff --git a/homeassistant/components/ping/strings.json b/homeassistant/components/ping/strings.json index 5b5c5da46bc..12bc1d25c7a 100644 --- a/homeassistant/components/ping/strings.json +++ b/homeassistant/components/ping/strings.json @@ -1,8 +1,34 @@ { - "services": { - "reload": { - "name": "[%key:common::action::reload%]", - "description": "Reloads ping sensors from the YAML-configuration." + "config": { + "step": { + "user": { + "title": "Add Ping", + "description": "Ping allows you to check the availability of a host.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "count": "Ping count" + }, + "data_description": { + "host": "The hostname or IP address of the device you want to ping." + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "invalid_ip_address": "Invalid IP address." + } + }, + "options": { + "step": { + "init": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "count": "[%key:component::ping::config::step::user::data::count%]" + } + } + }, + "abort": { + "invalid_ip_address": "[%key:component::ping::config::abort::invalid_ip_address%]" } } } diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index a33cef0e3a7..efad1b7466b 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -46,6 +46,8 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN + _previous_mode: str = "heating" + def __init__( self, coordinator: PlugwiseDataUpdateCoordinator, @@ -55,10 +57,15 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): super().__init__(coordinator, device_id) self._attr_extra_state_attributes = {} self._attr_unique_id = f"{device_id}-climate" - + self.cdr_gateway = coordinator.data.gateway + gateway_id: str = coordinator.data.gateway["gateway_id"] + self.gateway_data = coordinator.data.devices[gateway_id] # Determine supported features self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - if self.coordinator.data.gateway["cooling_present"]: + if ( + self.cdr_gateway["cooling_present"] + and self.cdr_gateway["smile_name"] != "Adam" + ): self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) @@ -67,12 +74,26 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): self._attr_preset_modes = presets self._attr_min_temp = self.device["thermostat"]["lower_bound"] - self._attr_max_temp = self.device["thermostat"]["upper_bound"] + self._attr_max_temp = min(self.device["thermostat"]["upper_bound"], 35.0) # Ensure we don't drop below 0.1 self._attr_target_temperature_step = max( self.device["thermostat"]["resolution"], 0.1 ) + def _previous_action_mode(self, coordinator: PlugwiseDataUpdateCoordinator) -> None: + """Return the previous action-mode when the regulation-mode is not heating or cooling. + + Helper for set_hvac_mode(). + """ + # When no cooling available, _previous_mode is always heating + if ( + "regulation_modes" in self.gateway_data + and "cooling" in self.gateway_data["regulation_modes"] + ): + mode = self.gateway_data["select_regulation_mode"] + if mode in ("cooling", "heating"): + self._previous_mode = mode + @property def current_temperature(self) -> float: """Return the current temperature.""" @@ -105,33 +126,46 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): @property def hvac_mode(self) -> HVACMode: - """Return HVAC operation ie. auto, heat, or heat_cool mode.""" + """Return HVAC operation ie. auto, cool, heat, heat_cool, or off mode.""" if (mode := self.device.get("mode")) is None or mode not in self.hvac_modes: return HVACMode.HEAT return HVACMode(mode) @property def hvac_modes(self) -> list[HVACMode]: - """Return the list of available HVACModes.""" - hvac_modes = [HVACMode.HEAT] - if self.coordinator.data.gateway["cooling_present"]: - hvac_modes = [HVACMode.HEAT_COOL] + """Return a list of available HVACModes.""" + hvac_modes: list[HVACMode] = [] + if "regulation_modes" in self.gateway_data: + hvac_modes.append(HVACMode.OFF) if self.device["available_schedules"] != ["None"]: hvac_modes.append(HVACMode.AUTO) + if self.cdr_gateway["cooling_present"]: + if "regulation_modes" in self.gateway_data: + if self.gateway_data["select_regulation_mode"] == "cooling": + hvac_modes.append(HVACMode.COOL) + if self.gateway_data["select_regulation_mode"] == "heating": + hvac_modes.append(HVACMode.HEAT) + else: + hvac_modes.append(HVACMode.HEAT_COOL) + else: + hvac_modes.append(HVACMode.HEAT) + return hvac_modes @property - def hvac_action(self) -> HVACAction | None: + def hvac_action(self) -> HVACAction: """Return the current running hvac operation if supported.""" - heater: str | None = self.coordinator.data.gateway["heater_id"] - if heater: - heater_data = self.coordinator.data.devices[heater] - if heater_data["binary_sensors"]["heating_state"]: - return HVACAction.HEATING - if heater_data["binary_sensors"].get("cooling_state"): - return HVACAction.COOLING + # Keep track of the previous action-mode + self._previous_action_mode(self.coordinator) + + heater: str = self.coordinator.data.gateway["heater_id"] + heater_data = self.coordinator.data.devices[heater] + if heater_data["binary_sensors"]["heating_state"]: + return HVACAction.HEATING + if heater_data["binary_sensors"].get("cooling_state", False): + return HVACAction.COOLING return HVACAction.IDLE @@ -168,9 +202,18 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): if hvac_mode not in self.hvac_modes: raise HomeAssistantError("Unsupported hvac_mode") - await self.coordinator.api.set_schedule_state( - self.device["location"], "on" if hvac_mode == HVACMode.AUTO else "off" - ) + if hvac_mode == self.hvac_mode: + return + + if hvac_mode == HVACMode.OFF: + await self.coordinator.api.set_regulation_mode(hvac_mode) + else: + await self.coordinator.api.set_schedule_state( + self.device["location"], + "on" if hvac_mode == HVACMode.AUTO else "off", + ) + if self.hvac_mode == HVACMode.OFF: + await self.coordinator.api.set_regulation_mode(self._previous_mode) @plugwise_command async def async_set_preset_mode(self, preset_mode: str) -> None: diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 1155aaffdf8..1373ba40fa3 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": ["crcmod", "plugwise"], - "requirements": ["plugwise==0.33.2"], + "requirements": ["plugwise==0.34.3"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 9865aec2242..2c87edddf04 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -23,19 +23,11 @@ from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity -@dataclass -class PlugwiseEntityDescriptionMixin: - """Mixin values for Plugwise entities.""" - - command: Callable[[Smile, str, str, float], Awaitable[None]] - - -@dataclass -class PlugwiseNumberEntityDescription( - NumberEntityDescription, PlugwiseEntityDescriptionMixin -): +@dataclass(kw_only=True) +class PlugwiseNumberEntityDescription(NumberEntityDescription): """Class describing Plugwise Number entities.""" + command: Callable[[Smile, str, str, float], Awaitable[None]] key: NumberType diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index 138e5fe3b59..c12ca671554 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -18,21 +18,13 @@ from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity -@dataclass -class PlugwiseSelectDescriptionMixin: - """Mixin values for Plugwise Select entities.""" - - command: Callable[[Smile, str, str], Awaitable[None]] - options_key: SelectOptionsType - - -@dataclass -class PlugwiseSelectEntityDescription( - SelectEntityDescription, PlugwiseSelectDescriptionMixin -): +@dataclass(kw_only=True) +class PlugwiseSelectEntityDescription(SelectEntityDescription): """Class describing Plugwise Select entities.""" + command: Callable[[Smile, str, str], Awaitable[None]] key: SelectType + options_key: SelectOptionsType SELECT_TYPES = ( diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 663461ceaa1..b18716d8020 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.14.0"] + "requirements": ["bluetooth-data-tools==1.15.0"] } diff --git a/homeassistant/components/private_ble_device/sensor.py b/homeassistant/components/private_ble_device/sensor.py index b332d057ba9..d15ed1163b7 100644 --- a/homeassistant/components/private_ble_device/sensor.py +++ b/homeassistant/components/private_ble_device/sensor.py @@ -83,13 +83,17 @@ SENSOR_DESCRIPTIONS = ( native_unit_of_measurement=UnitOfTime.SECONDS, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda hass, service_info: bluetooth.async_get_learned_advertising_interval( - hass, service_info.address - ) - or bluetooth.async_get_fallback_availability_interval( - hass, service_info.address - ) - or bluetooth.FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + value_fn=( + lambda hass, service_info: ( + bluetooth.async_get_learned_advertising_interval( + hass, service_info.address + ) + or bluetooth.async_get_fallback_availability_interval( + hass, service_info.address + ) + or bluetooth.FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + ) + ), suggested_display_precision=1, ), ) diff --git a/homeassistant/components/progettihwsw/strings.json b/homeassistant/components/progettihwsw/strings.json index bb98d565594..d50c6f8d4e3 100644 --- a/homeassistant/components/progettihwsw/strings.json +++ b/homeassistant/components/progettihwsw/strings.json @@ -13,6 +13,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your ProgettiHWSW board." } }, "relay_modes": { diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index 561657dcffa..7beac4cc54b 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -354,18 +354,18 @@ class PrometheusMetrics: value = self.state_as_number(state) metric.labels(**self._labels(state)).set(value) - def _handle_input_number(self, state): + def _numeric_handler(self, state, domain, title): if unit := self._unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)): metric = self._metric( - f"input_number_state_{unit}", + f"{domain}_state_{unit}", self.prometheus_cli.Gauge, - f"State of the input number measured in {unit}", + f"State of the {title} measured in {unit}", ) else: metric = self._metric( - "input_number_state", + f"{domain}_state", self.prometheus_cli.Gauge, - "State of the input number", + f"State of the {title}", ) with suppress(ValueError): @@ -379,6 +379,12 @@ class PrometheusMetrics: ) metric.labels(**self._labels(state)).set(value) + def _handle_input_number(self, state): + self._numeric_handler(state, "input_number", "input number") + + def _handle_number(self, state): + self._numeric_handler(state, "number", "number") + def _handle_device_tracker(self, state): metric = self._metric( "device_tracker_state", diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index 23a8fc3bf64..4012d6e8ea1 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -5,46 +5,27 @@ import logging import voluptuous as vol -from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, - CONF_DEVICES, - CONF_UNIT_OF_MEASUREMENT, - CONF_ZONE, - UnitOfLength, -) -from homeassistant.core import HomeAssistant, State, callback +from homeassistant.const import CONF_DEVICES, CONF_UNIT_OF_MEASUREMENT, CONF_ZONE +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.typing import ConfigType -from homeassistant.util.location import distance -from homeassistant.util.unit_conversion import DistanceConverter +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + ATTR_DIR_OF_TRAVEL, + ATTR_NEAREST, + CONF_IGNORED_ZONES, + CONF_TOLERANCE, + DEFAULT_PROXIMITY_ZONE, + DEFAULT_TOLERANCE, + DOMAIN, + UNITS, +) +from .coordinator import ProximityDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -ATTR_DIR_OF_TRAVEL = "dir_of_travel" -ATTR_DIST_FROM = "dist_to_zone" -ATTR_NEAREST = "nearest" - -CONF_IGNORED_ZONES = "ignored_zones" -CONF_TOLERANCE = "tolerance" - -DEFAULT_DIR_OF_TRAVEL = "not set" -DEFAULT_DIST_TO_ZONE = "not set" -DEFAULT_NEAREST = "not set" -DEFAULT_PROXIMITY_ZONE = "home" -DEFAULT_TOLERANCE = 1 -DOMAIN = "proximity" - -UNITS = [ - UnitOfLength.METERS, - UnitOfLength.KILOMETERS, - UnitOfLength.FEET, - UnitOfLength.YARDS, - UnitOfLength.MILES, -] - ZONE_SCHEMA = vol.Schema( { vol.Optional(CONF_ZONE, default=DEFAULT_PROXIMITY_ZONE): cv.string, @@ -62,52 +43,31 @@ CONFIG_SCHEMA = vol.Schema( ) -@callback -def async_setup_proximity_component( - hass: HomeAssistant, name: str, config: ConfigType -) -> bool: - """Set up the individual proximity component.""" - ignored_zones: list[str] = config[CONF_IGNORED_ZONES] - proximity_devices: list[str] = config[CONF_DEVICES] - tolerance: int = config[CONF_TOLERANCE] - proximity_zone = config[CONF_ZONE] - unit_of_measurement: str = config.get( - CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit - ) - zone_friendly_name = name - - proximity = Proximity( - hass, - zone_friendly_name, - DEFAULT_DIST_TO_ZONE, - DEFAULT_DIR_OF_TRAVEL, - DEFAULT_NEAREST, - ignored_zones, - proximity_devices, - tolerance, - proximity_zone, - unit_of_measurement, - ) - proximity.entity_id = f"{DOMAIN}.{zone_friendly_name}" - - proximity.async_write_ha_state() - - async_track_state_change( - hass, proximity_devices, proximity.async_check_proximity_state_change - ) - - return True - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Get the zones and offsets from configuration.yaml.""" + hass.data.setdefault(DOMAIN, {}) for zone, proximity_config in config[DOMAIN].items(): - async_setup_proximity_component(hass, zone, proximity_config) + _LOGGER.debug("setup %s with config:%s", zone, proximity_config) + + coordinator = ProximityDataUpdateCoordinator(hass, zone, proximity_config) + + async_track_state_change( + hass, + proximity_config[CONF_DEVICES], + coordinator.async_check_proximity_state_change, + ) + + await coordinator.async_refresh() + hass.data[DOMAIN][zone] = coordinator + + proximity = Proximity(hass, zone, coordinator) + await proximity.async_added_to_hass() + proximity.async_write_ha_state() return True -class Proximity(Entity): +class Proximity(CoordinatorEntity[ProximityDataUpdateCoordinator]): """Representation of a Proximity.""" # This entity is legacy and does not have a platform. @@ -117,203 +77,26 @@ class Proximity(Entity): def __init__( self, hass: HomeAssistant, - zone_friendly_name: str, - dist_to: str, - dir_of_travel: str, - nearest: str, - ignored_zones: list[str], - proximity_devices: list[str], - tolerance: int, - proximity_zone: str, - unit_of_measurement: str, + friendly_name: str, + coordinator: ProximityDataUpdateCoordinator, ) -> None: """Initialize the proximity.""" + super().__init__(coordinator) self.hass = hass - self.friendly_name = zone_friendly_name - self.dist_to: str | int = dist_to - self.dir_of_travel = dir_of_travel - self.nearest = nearest - self.ignored_zones = ignored_zones - self.proximity_devices = proximity_devices - self.tolerance = tolerance - self.proximity_zone = proximity_zone - self._unit_of_measurement = unit_of_measurement + self.entity_id = f"{DOMAIN}.{friendly_name}" + + self._attr_name = friendly_name + self._attr_unit_of_measurement = self.coordinator.unit_of_measurement @property - def name(self) -> str: - """Return the name of the entity.""" - return self.friendly_name - - @property - def state(self) -> str | int: + def state(self) -> str | int | float: """Return the state.""" - return self.dist_to - - @property - def unit_of_measurement(self) -> str: - """Return the unit of measurement of this entity.""" - return self._unit_of_measurement + return self.coordinator.data["dist_to_zone"] @property def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" - return {ATTR_DIR_OF_TRAVEL: self.dir_of_travel, ATTR_NEAREST: self.nearest} - - @callback - def async_check_proximity_state_change( - self, entity: str, old_state: State | None, new_state: State | None - ) -> None: - """Perform the proximity checking.""" - if new_state is None: - return - - entity_name = new_state.name - devices_to_calculate = False - devices_in_zone = "" - - zone_state = self.hass.states.get(f"zone.{self.proximity_zone}") - proximity_latitude = ( - zone_state.attributes.get(ATTR_LATITUDE) if zone_state else None - ) - proximity_longitude = ( - zone_state.attributes.get(ATTR_LONGITUDE) if zone_state else None - ) - - # Check for devices in the monitored zone. - for device in self.proximity_devices: - if (device_state := self.hass.states.get(device)) is None: - devices_to_calculate = True - continue - - if device_state.state not in self.ignored_zones: - devices_to_calculate = True - - # Check the location of all devices. - if (device_state.state).lower() == (self.proximity_zone).lower(): - device_friendly = device_state.name - if devices_in_zone != "": - devices_in_zone = f"{devices_in_zone}, " - devices_in_zone = devices_in_zone + device_friendly - - # No-one to track so reset the entity. - if not devices_to_calculate: - self.dist_to = "not set" - self.dir_of_travel = "not set" - self.nearest = "not set" - self.async_write_ha_state() - return - - # At least one device is in the monitored zone so update the entity. - if devices_in_zone != "": - self.dist_to = 0 - self.dir_of_travel = "arrived" - self.nearest = devices_in_zone - self.async_write_ha_state() - return - - # We can't check proximity because latitude and longitude don't exist. - if "latitude" not in new_state.attributes: - return - - # Collect distances to the zone for all devices. - distances_to_zone: dict[str, float] = {} - for device in self.proximity_devices: - # Ignore devices in an ignored zone. - device_state = self.hass.states.get(device) - if not device_state or device_state.state in self.ignored_zones: - continue - - # Ignore devices if proximity cannot be calculated. - if "latitude" not in device_state.attributes: - continue - - # Calculate the distance to the proximity zone. - proximity = distance( - proximity_latitude, - proximity_longitude, - device_state.attributes[ATTR_LATITUDE], - device_state.attributes[ATTR_LONGITUDE], - ) - - # Add the device and distance to a dictionary. - if not proximity: - continue - distances_to_zone[device] = round( - DistanceConverter.convert( - proximity, UnitOfLength.METERS, self.unit_of_measurement - ), - 1, - ) - - # Loop through each of the distances collected and work out the - # closest. - closest_device: str | None = None - dist_to_zone: float | None = None - - for device, zone in distances_to_zone.items(): - if not dist_to_zone or zone < dist_to_zone: - closest_device = device - dist_to_zone = zone - - # If the closest device is one of the other devices. - if closest_device is not None and closest_device != entity: - self.dist_to = round(distances_to_zone[closest_device]) - self.dir_of_travel = "unknown" - device_state = self.hass.states.get(closest_device) - assert device_state - self.nearest = device_state.name - self.async_write_ha_state() - return - - # Stop if we cannot calculate the direction of travel (i.e. we don't - # have a previous state and a current LAT and LONG). - if old_state is None or "latitude" not in old_state.attributes: - self.dist_to = round(distances_to_zone[entity]) - self.dir_of_travel = "unknown" - self.nearest = entity_name - self.async_write_ha_state() - return - - # Reset the variables - distance_travelled: float = 0 - - # Calculate the distance travelled. - old_distance = distance( - proximity_latitude, - proximity_longitude, - old_state.attributes[ATTR_LATITUDE], - old_state.attributes[ATTR_LONGITUDE], - ) - new_distance = distance( - proximity_latitude, - proximity_longitude, - new_state.attributes[ATTR_LATITUDE], - new_state.attributes[ATTR_LONGITUDE], - ) - assert new_distance is not None and old_distance is not None - distance_travelled = round(new_distance - old_distance, 1) - - # Check for tolerance - if distance_travelled < self.tolerance * -1: - direction_of_travel = "towards" - elif distance_travelled > self.tolerance: - direction_of_travel = "away_from" - else: - direction_of_travel = "stationary" - - # Update the proximity entity - self.dist_to = ( - round(dist_to_zone) if dist_to_zone is not None else DEFAULT_DIST_TO_ZONE - ) - self.dir_of_travel = direction_of_travel - self.nearest = entity_name - self.async_write_ha_state() - _LOGGER.debug( - "proximity.%s update entity: distance=%s: direction=%s: device=%s", - self.friendly_name, - self.dist_to, - direction_of_travel, - entity_name, - ) - - _LOGGER.info("%s: proximity calculation complete", entity_name) + return { + ATTR_DIR_OF_TRAVEL: str(self.coordinator.data["dir_of_travel"]), + ATTR_NEAREST: str(self.coordinator.data["nearest"]), + } diff --git a/homeassistant/components/proximity/const.py b/homeassistant/components/proximity/const.py new file mode 100644 index 00000000000..a5cee0ffce3 --- /dev/null +++ b/homeassistant/components/proximity/const.py @@ -0,0 +1,25 @@ +"""Constants for Proximity integration.""" + +from homeassistant.const import UnitOfLength + +ATTR_DIR_OF_TRAVEL = "dir_of_travel" +ATTR_DIST_TO = "dist_to_zone" +ATTR_NEAREST = "nearest" + +CONF_IGNORED_ZONES = "ignored_zones" +CONF_TOLERANCE = "tolerance" + +DEFAULT_DIR_OF_TRAVEL = "not set" +DEFAULT_DIST_TO_ZONE = "not set" +DEFAULT_NEAREST = "not set" +DEFAULT_PROXIMITY_ZONE = "home" +DEFAULT_TOLERANCE = 1 +DOMAIN = "proximity" + +UNITS = [ + UnitOfLength.METERS, + UnitOfLength.KILOMETERS, + UnitOfLength.FEET, + UnitOfLength.YARDS, + UnitOfLength.MILES, +] diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py new file mode 100644 index 00000000000..1f1c96c9490 --- /dev/null +++ b/homeassistant/components/proximity/coordinator.py @@ -0,0 +1,257 @@ +"""Data update coordinator for the Proximity integration.""" + +from dataclasses import dataclass +import logging +from typing import TypedDict + +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_DEVICES, + CONF_UNIT_OF_MEASUREMENT, + CONF_ZONE, + UnitOfLength, +) +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util.location import distance +from homeassistant.util.unit_conversion import DistanceConverter + +from .const import ( + CONF_IGNORED_ZONES, + CONF_TOLERANCE, + DEFAULT_DIR_OF_TRAVEL, + DEFAULT_DIST_TO_ZONE, + DEFAULT_NEAREST, +) + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class StateChangedData: + """StateChangedData class.""" + + entity_id: str + old_state: State | None + new_state: State | None + + +class ProximityData(TypedDict): + """ProximityData type class.""" + + dist_to_zone: str | float + dir_of_travel: str | float + nearest: str | float + + +class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): + """Proximity data update coordinator.""" + + def __init__( + self, hass: HomeAssistant, friendly_name: str, config: ConfigType + ) -> None: + """Initialize the Proximity coordinator.""" + self.ignored_zones: list[str] = config[CONF_IGNORED_ZONES] + self.proximity_devices: list[str] = config[CONF_DEVICES] + self.tolerance: int = config[CONF_TOLERANCE] + self.proximity_zone: str = config[CONF_ZONE] + self.unit_of_measurement: str = config.get( + CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit + ) + self.friendly_name = friendly_name + + super().__init__( + hass, + _LOGGER, + name=friendly_name, + update_interval=None, + ) + + self.data = { + "dist_to_zone": DEFAULT_DIST_TO_ZONE, + "dir_of_travel": DEFAULT_DIR_OF_TRAVEL, + "nearest": DEFAULT_NEAREST, + } + + self.state_change_data: StateChangedData | None = None + + async def async_check_proximity_state_change( + self, entity: str, old_state: State | None, new_state: State | None + ) -> None: + """Fetch and process state change event.""" + if new_state is None: + _LOGGER.debug("no new_state -> abort") + return + + self.state_change_data = StateChangedData(entity, old_state, new_state) + await self.async_refresh() + + async def _async_update_data(self) -> ProximityData: + """Calculate Proximity data.""" + if ( + state_change_data := self.state_change_data + ) is None or state_change_data.new_state is None: + return self.data + + entity_name = state_change_data.new_state.name + devices_to_calculate = False + devices_in_zone = [] + + zone_state = self.hass.states.get(f"zone.{self.proximity_zone}") + proximity_latitude = ( + zone_state.attributes.get(ATTR_LATITUDE) if zone_state else None + ) + proximity_longitude = ( + zone_state.attributes.get(ATTR_LONGITUDE) if zone_state else None + ) + + # Check for devices in the monitored zone. + for device in self.proximity_devices: + if (device_state := self.hass.states.get(device)) is None: + devices_to_calculate = True + continue + + if device_state.state not in self.ignored_zones: + devices_to_calculate = True + + # Check the location of all devices. + if (device_state.state).lower() == (self.proximity_zone).lower(): + device_friendly = device_state.name + devices_in_zone.append(device_friendly) + + # No-one to track so reset the entity. + if not devices_to_calculate: + _LOGGER.debug("no devices_to_calculate -> abort") + return { + "dist_to_zone": DEFAULT_DIST_TO_ZONE, + "dir_of_travel": DEFAULT_DIR_OF_TRAVEL, + "nearest": DEFAULT_NEAREST, + } + + # At least one device is in the monitored zone so update the entity. + if devices_in_zone: + _LOGGER.debug("at least one device is in zone -> arrived") + return { + "dist_to_zone": 0, + "dir_of_travel": "arrived", + "nearest": ", ".join(devices_in_zone), + } + + # We can't check proximity because latitude and longitude don't exist. + if "latitude" not in state_change_data.new_state.attributes: + _LOGGER.debug("no latitude and longitude -> reset") + return self.data + + # Collect distances to the zone for all devices. + distances_to_zone: dict[str, float] = {} + for device in self.proximity_devices: + # Ignore devices in an ignored zone. + device_state = self.hass.states.get(device) + if not device_state or device_state.state in self.ignored_zones: + continue + + # Ignore devices if proximity cannot be calculated. + if "latitude" not in device_state.attributes: + continue + + # Calculate the distance to the proximity zone. + proximity = distance( + proximity_latitude, + proximity_longitude, + device_state.attributes[ATTR_LATITUDE], + device_state.attributes[ATTR_LONGITUDE], + ) + + # Add the device and distance to a dictionary. + if proximity is None: + continue + distances_to_zone[device] = round( + DistanceConverter.convert( + proximity, UnitOfLength.METERS, self.unit_of_measurement + ), + 1, + ) + + # Loop through each of the distances collected and work out the + # closest. + closest_device: str | None = None + dist_to_zone: float | None = None + + for device, zone in distances_to_zone.items(): + if not dist_to_zone or zone < dist_to_zone: + closest_device = device + dist_to_zone = zone + + # If the closest device is one of the other devices. + if closest_device is not None and closest_device != state_change_data.entity_id: + _LOGGER.debug("closest device is one of the other devices -> unknown") + device_state = self.hass.states.get(closest_device) + assert device_state + return { + "dist_to_zone": round(distances_to_zone[closest_device]), + "dir_of_travel": "unknown", + "nearest": device_state.name, + } + + # Stop if we cannot calculate the direction of travel (i.e. we don't + # have a previous state and a current LAT and LONG). + if ( + state_change_data.old_state is None + or "latitude" not in state_change_data.old_state.attributes + ): + _LOGGER.debug("no lat and lon in old_state -> unknown") + return { + "dist_to_zone": round(distances_to_zone[state_change_data.entity_id]), + "dir_of_travel": "unknown", + "nearest": entity_name, + } + + # Reset the variables + distance_travelled: float = 0 + + # Calculate the distance travelled. + old_distance = distance( + proximity_latitude, + proximity_longitude, + state_change_data.old_state.attributes[ATTR_LATITUDE], + state_change_data.old_state.attributes[ATTR_LONGITUDE], + ) + new_distance = distance( + proximity_latitude, + proximity_longitude, + state_change_data.new_state.attributes[ATTR_LATITUDE], + state_change_data.new_state.attributes[ATTR_LONGITUDE], + ) + assert new_distance is not None and old_distance is not None + distance_travelled = round(new_distance - old_distance, 1) + + # Check for tolerance + if distance_travelled < self.tolerance * -1: + direction_of_travel = "towards" + elif distance_travelled > self.tolerance: + direction_of_travel = "away_from" + else: + direction_of_travel = "stationary" + + # Update the proximity entity + dist_to: float | str + if dist_to_zone is not None: + dist_to = round(dist_to_zone) + else: + dist_to = DEFAULT_DIST_TO_ZONE + + _LOGGER.debug( + "%s updated: distance=%s: direction=%s: device=%s", + self.friendly_name, + dist_to, + direction_of_travel, + entity_name, + ) + + return { + "dist_to_zone": dist_to, + "dir_of_travel": direction_of_travel, + "nearest": entity_name, + } diff --git a/homeassistant/components/proximity/manifest.json b/homeassistant/components/proximity/manifest.json index c09a03b2438..3f1ea950d0e 100644 --- a/homeassistant/components/proximity/manifest.json +++ b/homeassistant/components/proximity/manifest.json @@ -1,7 +1,7 @@ { "domain": "proximity", "name": "Proximity", - "codeowners": [], + "codeowners": ["@mib1185"], "dependencies": ["device_tracker", "zone"], "documentation": "https://www.home-assistant.io/integrations/proximity", "iot_class": "calculated", diff --git a/homeassistant/components/pure_energie/manifest.json b/homeassistant/components/pure_energie/manifest.json index 4c83b5e3651..19098c41208 100644 --- a/homeassistant/components/pure_energie/manifest.json +++ b/homeassistant/components/pure_energie/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/pure_energie", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["gridnet==4.2.0"], + "requirements": ["gridnet==5.0.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/pvoutput/diagnostics.py b/homeassistant/components/pvoutput/diagnostics.py index 2aff3b20442..dfe215b7ddd 100644 --- a/homeassistant/components/pvoutput/diagnostics.py +++ b/homeassistant/components/pvoutput/diagnostics.py @@ -1,7 +1,6 @@ """Diagnostics support for PVOutput.""" from __future__ import annotations -import json from typing import Any from homeassistant.config_entries import ConfigEntry @@ -16,6 +15,4 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" coordinator: PVOutputDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - # Round-trip via JSON to trigger serialization - data: dict[str, Any] = json.loads(coordinator.data.json()) - return data + return coordinator.data.to_dict() diff --git a/homeassistant/components/pvoutput/manifest.json b/homeassistant/components/pvoutput/manifest.json index 787e59db3db..61bd6fd6164 100644 --- a/homeassistant/components/pvoutput/manifest.json +++ b/homeassistant/components/pvoutput/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["pvo==2.0.0"] + "requirements": ["pvo==2.1.1"] } diff --git a/homeassistant/components/pvoutput/sensor.py b/homeassistant/components/pvoutput/sensor.py index bcf869d3bba..d9ef71bee69 100644 --- a/homeassistant/components/pvoutput/sensor.py +++ b/homeassistant/components/pvoutput/sensor.py @@ -28,20 +28,13 @@ from .const import CONF_SYSTEM_ID, DOMAIN from .coordinator import PVOutputDataUpdateCoordinator -@dataclass -class PVOutputSensorEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(kw_only=True) +class PVOutputSensorEntityDescription(SensorEntityDescription): + """Describes a PVOutput sensor entity.""" value_fn: Callable[[Status], int | float | None] -@dataclass -class PVOutputSensorEntityDescription( - SensorEntityDescription, PVOutputSensorEntityDescriptionMixin -): - """Describes a PVOutput sensor entity.""" - - SENSORS: tuple[PVOutputSensorEntityDescription, ...] = ( PVOutputSensorEntityDescription( key="energy_consumption", diff --git a/homeassistant/components/pvpc_hourly_pricing/__init__.py b/homeassistant/components/pvpc_hourly_pricing/__init__.py index 808ff1b4cc4..7071000ffd9 100644 --- a/homeassistant/components/pvpc_hourly_pricing/__init__.py +++ b/homeassistant/components/pvpc_hourly_pricing/__init__.py @@ -2,38 +2,21 @@ from datetime import timedelta import logging -from aiopvpc import DEFAULT_POWER_KW, TARIFFS, EsiosApiData, PVPCData -import voluptuous as vol +from aiopvpc import BadApiTokenAuthError, EsiosApiData, PVPCData from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import ( - ATTR_POWER, - ATTR_POWER_P3, - ATTR_TARIFF, - DEFAULT_NAME, - DOMAIN, - PLATFORMS, -) +from .const import ATTR_POWER, ATTR_POWER_P3, ATTR_TARIFF, DOMAIN _LOGGER = logging.getLogger(__name__) -_DEFAULT_TARIFF = TARIFFS[0] -VALID_POWER = vol.All(vol.Coerce(float), vol.Range(min=1.0, max=15.0)) -VALID_TARIFF = vol.In(TARIFFS) -UI_CONFIG_SCHEMA = vol.Schema( - { - vol.Required(CONF_NAME, default=DEFAULT_NAME): str, - vol.Required(ATTR_TARIFF, default=_DEFAULT_TARIFF): VALID_TARIFF, - vol.Required(ATTR_POWER, default=DEFAULT_POWER_KW): VALID_POWER, - vol.Required(ATTR_POWER_P3, default=DEFAULT_POWER_KW): VALID_POWER, - } -) +PLATFORMS: list[Platform] = [Platform.SENSOR] CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @@ -52,7 +35,7 @@ async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" if any( entry.data.get(attrib) != entry.options.get(attrib) - for attrib in (ATTR_POWER, ATTR_POWER_P3) + for attrib in (ATTR_POWER, ATTR_POWER_P3, CONF_API_TOKEN) ): # update entry replacing data with new options hass.config_entries.async_update_entry( @@ -80,6 +63,7 @@ class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]): local_timezone=hass.config.time_zone, power=entry.data[ATTR_POWER], power_valley=entry.data[ATTR_POWER_P3], + api_token=entry.data.get(CONF_API_TOKEN), ) super().__init__( hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=30) @@ -93,7 +77,10 @@ class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]): async def _async_update_data(self) -> EsiosApiData: """Update electricity prices from the ESIOS API.""" - api_data = await self.api.async_update_all(self.data, dt_util.utcnow()) + try: + api_data = await self.api.async_update_all(self.data, dt_util.utcnow()) + except BadApiTokenAuthError as exc: + raise ConfigEntryAuthFailed from exc if ( not api_data or not api_data.sensors diff --git a/homeassistant/components/pvpc_hourly_pricing/config_flow.py b/homeassistant/components/pvpc_hourly_pricing/config_flow.py index 9412aa2e97d..66092cb9211 100644 --- a/homeassistant/components/pvpc_hourly_pricing/config_flow.py +++ b/homeassistant/components/pvpc_hourly_pricing/config_flow.py @@ -1,22 +1,49 @@ """Config flow for pvpc_hourly_pricing.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any +from aiopvpc import DEFAULT_POWER_KW, PVPCData import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_API_TOKEN, CONF_NAME from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import dt as dt_util -from . import CONF_NAME, UI_CONFIG_SCHEMA, VALID_POWER -from .const import ATTR_POWER, ATTR_POWER_P3, ATTR_TARIFF, DOMAIN +from .const import ( + ATTR_POWER, + ATTR_POWER_P3, + ATTR_TARIFF, + CONF_USE_API_TOKEN, + DEFAULT_NAME, + DEFAULT_TARIFF, + DOMAIN, + VALID_POWER, + VALID_TARIFF, +) + +_MAIL_TO_LINK = ( + "[consultasios@ree.es]" + "(mailto:consultasios@ree.es?subject=Personal%20token%20request)" +) class TariffSelectorConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle config flow for `pvpc_hourly_pricing`.""" VERSION = 1 + _name: str | None = None + _tariff: str | None = None + _power: float | None = None + _power_p3: float | None = None + _use_api_token: bool = False + _api_token: str | None = None + _api: PVPCData | None = None + _reauth_entry: config_entries.ConfigEntry | None = None @staticmethod @callback @@ -33,36 +60,184 @@ class TariffSelectorConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: await self.async_set_unique_id(user_input[ATTR_TARIFF]) self._abort_if_unique_id_configured() - return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) + if not user_input[CONF_USE_API_TOKEN]: + return self.async_create_entry( + title=user_input[CONF_NAME], + data={ + CONF_NAME: user_input[CONF_NAME], + ATTR_TARIFF: user_input[ATTR_TARIFF], + ATTR_POWER: user_input[ATTR_POWER], + ATTR_POWER_P3: user_input[ATTR_POWER_P3], + CONF_API_TOKEN: None, + }, + ) - return self.async_show_form(step_id="user", data_schema=UI_CONFIG_SCHEMA) + self._name = user_input[CONF_NAME] + self._tariff = user_input[ATTR_TARIFF] + self._power = user_input[ATTR_POWER] + self._power_p3 = user_input[ATTR_POWER_P3] + self._use_api_token = user_input[CONF_USE_API_TOKEN] + return await self.async_step_api_token() + + data_schema = vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, + vol.Required(ATTR_TARIFF, default=DEFAULT_TARIFF): VALID_TARIFF, + vol.Required(ATTR_POWER, default=DEFAULT_POWER_KW): VALID_POWER, + vol.Required(ATTR_POWER_P3, default=DEFAULT_POWER_KW): VALID_POWER, + vol.Required(CONF_USE_API_TOKEN, default=False): bool, + } + ) + return self.async_show_form(step_id="user", data_schema=data_schema) + + async def async_step_api_token( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle optional step to define API token for extra sensors.""" + if user_input is not None: + self._api_token = user_input[CONF_API_TOKEN] + return await self._async_verify( + "api_token", + data_schema=vol.Schema( + {vol.Required(CONF_API_TOKEN, default=self._api_token): str} + ), + ) + return self.async_show_form( + step_id="api_token", + data_schema=vol.Schema( + {vol.Required(CONF_API_TOKEN, default=self._api_token): str} + ), + description_placeholders={"mail_to_link": _MAIL_TO_LINK}, + ) + + async def _async_verify(self, step_id: str, data_schema: vol.Schema) -> FlowResult: + """Attempt to verify the provided configuration.""" + errors: dict[str, str] = {} + auth_ok = True + if self._use_api_token: + if not self._api: + self._api = PVPCData(session=async_get_clientsession(self.hass)) + auth_ok = await self._api.check_api_token(dt_util.utcnow(), self._api_token) + if not auth_ok: + errors["base"] = "invalid_auth" + return self.async_show_form( + step_id=step_id, + data_schema=data_schema, + errors=errors, + description_placeholders={"mail_to_link": _MAIL_TO_LINK}, + ) + + data = { + CONF_NAME: self._name, + ATTR_TARIFF: self._tariff, + ATTR_POWER: self._power, + ATTR_POWER_P3: self._power_p3, + CONF_API_TOKEN: self._api_token if self._use_api_token else None, + } + if self._reauth_entry: + self.hass.config_entries.async_update_entry(self._reauth_entry, data=data) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + + assert self._name is not None + return self.async_create_entry(title=self._name, data=data) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle re-authentication with ESIOS Token.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + self._api_token = entry_data.get(CONF_API_TOKEN) + self._use_api_token = self._api_token is not None + self._name = entry_data[CONF_NAME] + self._tariff = entry_data[ATTR_TARIFF] + self._power = entry_data[ATTR_POWER] + self._power_p3 = entry_data[ATTR_POWER_P3] + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + data_schema = vol.Schema( + { + vol.Required(CONF_USE_API_TOKEN, default=self._use_api_token): bool, + vol.Optional(CONF_API_TOKEN, default=self._api_token): str, + } + ) + if user_input: + self._api_token = user_input[CONF_API_TOKEN] + self._use_api_token = user_input[CONF_USE_API_TOKEN] + return await self._async_verify("reauth_confirm", data_schema) + return self.async_show_form(step_id="reauth_confirm", data_schema=data_schema) -class PVPCOptionsFlowHandler(config_entries.OptionsFlow): +class PVPCOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): """Handle PVPC options.""" - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry + _power: float | None = None + _power_p3: float | None = None + + async def async_step_api_token( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle optional step to define API token for extra sensors.""" + if user_input is not None and user_input.get(CONF_API_TOKEN): + return self.async_create_entry( + title="", + data={ + ATTR_POWER: self._power, + ATTR_POWER_P3: self._power_p3, + CONF_API_TOKEN: user_input[CONF_API_TOKEN], + }, + ) + + # Fill options with entry data + api_token = self.options.get( + CONF_API_TOKEN, self.config_entry.data.get(CONF_API_TOKEN) + ) + return self.async_show_form( + step_id="api_token", + data_schema=vol.Schema( + {vol.Required(CONF_API_TOKEN, default=api_token): str} + ), + description_placeholders={"mail_to_link": _MAIL_TO_LINK}, + ) async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage the options.""" if user_input is not None: - return self.async_create_entry(title="", data=user_input) + if user_input[CONF_USE_API_TOKEN]: + self._power = user_input[ATTR_POWER] + self._power_p3 = user_input[ATTR_POWER_P3] + return await self.async_step_api_token(user_input) + return self.async_create_entry( + title="", + data={ + ATTR_POWER: user_input[ATTR_POWER], + ATTR_POWER_P3: user_input[ATTR_POWER_P3], + CONF_API_TOKEN: None, + }, + ) # Fill options with entry data - power = self.config_entry.options.get( - ATTR_POWER, self.config_entry.data[ATTR_POWER] - ) - power_valley = self.config_entry.options.get( + power = self.options.get(ATTR_POWER, self.config_entry.data[ATTR_POWER]) + power_valley = self.options.get( ATTR_POWER_P3, self.config_entry.data[ATTR_POWER_P3] ) + api_token = self.options.get( + CONF_API_TOKEN, self.config_entry.data.get(CONF_API_TOKEN) + ) + use_api_token = api_token is not None schema = vol.Schema( { vol.Required(ATTR_POWER, default=power): VALID_POWER, vol.Required(ATTR_POWER_P3, default=power_valley): VALID_POWER, + vol.Required(CONF_USE_API_TOKEN, default=use_api_token): bool, } ) return self.async_show_form(step_id="init", data_schema=schema) diff --git a/homeassistant/components/pvpc_hourly_pricing/const.py b/homeassistant/components/pvpc_hourly_pricing/const.py index 186ee1171f3..ea4d97620ec 100644 --- a/homeassistant/components/pvpc_hourly_pricing/const.py +++ b/homeassistant/components/pvpc_hourly_pricing/const.py @@ -1,9 +1,15 @@ """Constant values for pvpc_hourly_pricing.""" -from homeassistant.const import Platform +from aiopvpc import TARIFFS +import voluptuous as vol DOMAIN = "pvpc_hourly_pricing" -PLATFORMS = [Platform.SENSOR] + ATTR_POWER = "power" ATTR_POWER_P3 = "power_p3" ATTR_TARIFF = "tariff" DEFAULT_NAME = "PVPC" +CONF_USE_API_TOKEN = "use_api_token" + +VALID_POWER = vol.All(vol.Coerce(float), vol.Range(min=1.0, max=15.0)) +VALID_TARIFF = vol.In(TARIFFS) +DEFAULT_TARIFF = TARIFFS[0] diff --git a/homeassistant/components/pvpc_hourly_pricing/strings.json b/homeassistant/components/pvpc_hourly_pricing/strings.json index 1a0055ddbac..4236709fdfa 100644 --- a/homeassistant/components/pvpc_hourly_pricing/strings.json +++ b/homeassistant/components/pvpc_hourly_pricing/strings.json @@ -6,12 +6,31 @@ "name": "Sensor Name", "tariff": "Applicable tariff by geographic zone", "power": "Contracted power (kW)", - "power_p3": "Contracted power for valley period P3 (kW)" + "power_p3": "Contracted power for valley period P3 (kW)", + "use_api_token": "Enable ESIOS Personal API token for private access" + } + }, + "api_token": { + "title": "ESIOS API token", + "description": "To use the extended API you must request a personal token by mailing to {mail_to_link}.", + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]" + } + }, + "reauth_confirm": { + "data": { + "description": "Re-authenticate with a valid token or disable it", + "use_api_token": "[%key:component::pvpc_hourly_pricing::config::step::user::data::use_api_token%]", + "api_token": "[%key:common::config_flow::data::api_token%]" } } }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { @@ -19,7 +38,15 @@ "init": { "data": { "power": "[%key:component::pvpc_hourly_pricing::config::step::user::data::power%]", - "power_p3": "[%key:component::pvpc_hourly_pricing::config::step::user::data::power_p3%]" + "power_p3": "[%key:component::pvpc_hourly_pricing::config::step::user::data::power_p3%]", + "use_api_token": "[%key:component::pvpc_hourly_pricing::config::step::user::data::use_api_token%]" + } + }, + "api_token": { + "title": "[%key:component::pvpc_hourly_pricing::config::step::api_token::title%]", + "description": "[%key:component::pvpc_hourly_pricing::config::step::api_token::description%]", + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]" } } } diff --git a/homeassistant/components/python_script/manifest.json b/homeassistant/components/python_script/manifest.json index bd034053a34..dcc0e38c737 100644 --- a/homeassistant/components/python_script/manifest.json +++ b/homeassistant/components/python_script/manifest.json @@ -5,8 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/python_script", "loggers": ["RestrictedPython"], "quality_scale": "internal", - "requirements": [ - "RestrictedPython==6.2;python_version<'3.12'", - "RestrictedPython==7.0a1.dev0;python_version>='3.12'" - ] + "requirements": ["RestrictedPython==7.0"] } diff --git a/homeassistant/components/qnap/config_flow.py b/homeassistant/components/qnap/config_flow.py index 689fe30a870..04b5340fa8a 100644 --- a/homeassistant/components/qnap/config_flow.py +++ b/homeassistant/components/qnap/config_flow.py @@ -11,7 +11,6 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import ( CONF_HOST, - CONF_MONITORED_CONDITIONS, CONF_PASSWORD, CONF_PORT, CONF_SSL, @@ -22,9 +21,6 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv from .const import ( - CONF_DRIVES, - CONF_NICS, - CONF_VOLUMES, DEFAULT_PORT, DEFAULT_SSL, DEFAULT_TIMEOUT, @@ -51,14 +47,6 @@ class QnapConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: - """Set the config entry up from yaml.""" - import_info.pop(CONF_MONITORED_CONDITIONS, None) - import_info.pop(CONF_NICS, None) - import_info.pop(CONF_DRIVES, None) - import_info.pop(CONF_VOLUMES, None) - return await self.async_step_user(import_info) - async def async_step_user( self, user_input: dict[str, Any] | None = None, diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index dfd03deca16..4677d2aabb6 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -1,13 +1,8 @@ """Support for QNAP NAS Sensors.""" from __future__ import annotations -import logging - -import voluptuous as vol - from homeassistant import config_entries from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -15,40 +10,20 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( ATTR_NAME, - CONF_HOST, - CONF_MONITORED_CONDITIONS, - CONF_PASSWORD, - CONF_PORT, - CONF_SSL, - CONF_TIMEOUT, - CONF_USERNAME, - CONF_VERIFY_SSL, PERCENTAGE, UnitOfDataRate, UnitOfInformation, UnitOfTemperature, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - CONF_DRIVES, - CONF_NICS, - CONF_VOLUMES, - DEFAULT_PORT, - DEFAULT_TIMEOUT, - DOMAIN, -) +from .const import DOMAIN from .coordinator import QnapCoordinator -_LOGGER = logging.getLogger(__name__) - ATTR_DRIVE = "Drive" ATTR_IP = "IP Address" ATTR_MAC = "MAC Address" @@ -221,54 +196,6 @@ SENSOR_KEYS: list[str] = [ ) ] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_SSL, default=False): cv.boolean, - vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, [vol.In(SENSOR_KEYS)] - ), - vol.Optional(CONF_NICS): cv.ensure_list, - vol.Optional(CONF_DRIVES): cv.ensure_list, - vol.Optional(CONF_VOLUMES): cv.ensure_list, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the qnap sensor platform from yaml.""" - - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2023.12.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "QNAP", - }, - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config - ) - ) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/qnap/strings.json b/homeassistant/components/qnap/strings.json index a5fa3c8a897..d535b9f0e87 100644 --- a/homeassistant/components/qnap/strings.json +++ b/homeassistant/components/qnap/strings.json @@ -11,6 +11,9 @@ "port": "[%key:common::config_flow::data::port%]", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of your QNAP device." } } }, diff --git a/homeassistant/components/radio_browser/manifest.json b/homeassistant/components/radio_browser/manifest.json index 035c4bdda45..3aa94e0d402 100644 --- a/homeassistant/components/radio_browser/manifest.json +++ b/homeassistant/components/radio_browser/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/radio_browser", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["radios==0.1.1"] + "requirements": ["radios==0.2.0"] } diff --git a/homeassistant/components/radiotherm/strings.json b/homeassistant/components/radiotherm/strings.json index 693811f59ab..e76bd2d3f2d 100644 --- a/homeassistant/components/radiotherm/strings.json +++ b/homeassistant/components/radiotherm/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Radio Thermostat." } }, "confirm": { diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index e7a7c1200b9..e5731dc08fe 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -10,10 +10,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac -from homeassistant.helpers.entity_registry import async_entries_for_config_entry from .const import CONF_SERIAL_NUMBER from .coordinator import RainbirdData @@ -55,6 +54,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: format_mac(mac_address), str(entry.data[CONF_SERIAL_NUMBER]), ) + _async_fix_device_id( + hass, + dr.async_get(hass), + entry.entry_id, + format_mac(mac_address), + str(entry.data[CONF_SERIAL_NUMBER]), + ) try: model_info = await controller.get_model_and_version() @@ -124,7 +130,7 @@ def _async_fix_entity_unique_id( serial_number: str, ) -> None: """Migrate existing entity if current one can't be found and an old one exists.""" - entity_entries = async_entries_for_config_entry(entity_registry, config_entry_id) + entity_entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) for entity_entry in entity_entries: unique_id = str(entity_entry.unique_id) if unique_id.startswith(mac_address): @@ -137,6 +143,70 @@ def _async_fix_entity_unique_id( ) +def _async_device_entry_to_keep( + old_entry: dr.DeviceEntry, new_entry: dr.DeviceEntry +) -> dr.DeviceEntry: + """Determine which device entry to keep when there are duplicates. + + As we transitioned to new unique ids, we did not update existing device entries + and as a result there are devices with both the old and new unique id format. We + have to pick which one to keep, and preferably this can repair things if the + user previously renamed devices. + """ + # Prefer the new device if the user already gave it a name or area. Otherwise, + # do the same for the old entry. If no entries have been modified then keep the new one. + if new_entry.disabled_by is None and ( + new_entry.area_id is not None or new_entry.name_by_user is not None + ): + return new_entry + if old_entry.disabled_by is None and ( + old_entry.area_id is not None or old_entry.name_by_user is not None + ): + return old_entry + return new_entry if new_entry.disabled_by is None else old_entry + + +def _async_fix_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry_id: str, + mac_address: str, + serial_number: str, +) -> None: + """Migrate existing device identifiers to the new format. + + This will rename any device ids that are prefixed with the serial number to be prefixed + with the mac address. This also cleans up from a bug that allowed devices to exist + in both the old and new format. + """ + device_entries = dr.async_entries_for_config_entry(device_registry, config_entry_id) + device_entry_map = {} + migrations = {} + for device_entry in device_entries: + unique_id = str(next(iter(device_entry.identifiers))[1]) + device_entry_map[unique_id] = device_entry + if (suffix := unique_id.removeprefix(str(serial_number))) != unique_id: + migrations[unique_id] = f"{mac_address}{suffix}" + + for unique_id, new_unique_id in migrations.items(): + old_entry = device_entry_map[unique_id] + if (new_entry := device_entry_map.get(new_unique_id)) is not None: + # Device entries exist for both the old and new format and one must be removed + entry_to_keep = _async_device_entry_to_keep(old_entry, new_entry) + if entry_to_keep == new_entry: + _LOGGER.debug("Removing device entry %s", unique_id) + device_registry.async_remove_device(old_entry.id) + continue + # Remove new entry and update old entry to new id below + _LOGGER.debug("Removing device entry %s", new_unique_id) + device_registry.async_remove_device(new_entry.id) + + _LOGGER.debug("Updating device id from %s to %s", unique_id, new_unique_id) + device_registry.async_update_device( + old_entry.id, new_identifiers={(DOMAIN, new_unique_id)} + ) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json index 6046189ddc4..ea0d64f6208 100644 --- a/homeassistant/components/rainbird/strings.json +++ b/homeassistant/components/rainbird/strings.json @@ -7,6 +7,9 @@ "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 Rain Bird device." } } }, diff --git a/homeassistant/components/rainforest_eagle/strings.json b/homeassistant/components/rainforest_eagle/strings.json index 58c7f6bd795..7b5054bfb0f 100644 --- a/homeassistant/components/rainforest_eagle/strings.json +++ b/homeassistant/components/rainforest_eagle/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "cloud_id": "Cloud ID", "install_code": "Installation Code" + }, + "data_description": { + "host": "The hostname or IP address of your Rainforest gateway." } } }, diff --git a/homeassistant/components/random/binary_sensor.py b/homeassistant/components/random/binary_sensor.py index 9ada2ecd621..0c5b4a8b0dd 100644 --- a/homeassistant/components/random/binary_sensor.py +++ b/homeassistant/components/random/binary_sensor.py @@ -54,31 +54,14 @@ async def async_setup_entry( class RandomBinarySensor(BinarySensorEntity): """Representation of a Random binary sensor.""" - _state: bool | None = None - def __init__(self, config: Mapping[str, Any], entry_id: str | None = None) -> None: """Initialize the Random binary sensor.""" - self._name = config.get(CONF_NAME) - self._device_class = config.get(CONF_DEVICE_CLASS) + self._attr_name = config.get(CONF_NAME) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) if entry_id: self._attr_unique_id = entry_id - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._state - - @property - def device_class(self): - """Return the sensor class of the sensor.""" - return self._device_class - async def async_update(self) -> None: """Get new state and update the sensor's state.""" - self._state = bool(getrandbits(1)) + self._attr_is_on = bool(getrandbits(1)) diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index 8e77f026253..f1ca4290d83 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -65,40 +65,21 @@ async def async_setup_entry( class RandomSensor(SensorEntity): """Representation of a Random number sensor.""" - _attr_icon = "mdi:hanger" - _state: int | None = None - def __init__(self, config: Mapping[str, Any], entry_id: str | None = None) -> None: """Initialize the Random sensor.""" - self._name = config.get(CONF_NAME) + self._attr_name = config.get(CONF_NAME) self._minimum = config.get(CONF_MINIMUM, DEFAULT_MIN) self._maximum = config.get(CONF_MAXIMUM, DEFAULT_MAX) - self._unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) self._attr_device_class = config.get(CONF_DEVICE_CLASS) + self._attr_extra_state_attributes = { + ATTR_MAXIMUM: self._maximum, + ATTR_MINIMUM: self._minimum, + } if entry_id: self._attr_unique_id = entry_id - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def native_value(self): - """Return the state of the device.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit this state is expressed in.""" - return self._unit_of_measurement - - @property - def extra_state_attributes(self): - """Return the attributes of the sensor.""" - return {ATTR_MAXIMUM: self._maximum, ATTR_MINIMUM: self._minimum} - async def async_update(self) -> None: """Get a new number and updates the states.""" - self._state = randrange(self._minimum, self._maximum + 1) + self._attr_native_value = randrange(self._minimum, self._maximum + 1) diff --git a/homeassistant/components/rdw/binary_sensor.py b/homeassistant/components/rdw/binary_sensor.py index 16a93485b36..96311266db4 100644 --- a/homeassistant/components/rdw/binary_sensor.py +++ b/homeassistant/components/rdw/binary_sensor.py @@ -23,20 +23,13 @@ from homeassistant.helpers.update_coordinator import ( from .const import DOMAIN -@dataclass -class RDWBinarySensorEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(kw_only=True) +class RDWBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes RDW binary sensor entity.""" is_on_fn: Callable[[Vehicle], bool | None] -@dataclass -class RDWBinarySensorEntityDescription( - BinarySensorEntityDescription, RDWBinarySensorEntityDescriptionMixin -): - """Describes RDW binary sensor entity.""" - - BINARY_SENSORS: tuple[RDWBinarySensorEntityDescription, ...] = ( RDWBinarySensorEntityDescription( key="liability_insured", diff --git a/homeassistant/components/rdw/diagnostics.py b/homeassistant/components/rdw/diagnostics.py index 13c762f695f..dbf3d8e21c0 100644 --- a/homeassistant/components/rdw/diagnostics.py +++ b/homeassistant/components/rdw/diagnostics.py @@ -1,7 +1,6 @@ """Diagnostics support for RDW.""" from __future__ import annotations -import json from typing import Any from vehicle import Vehicle @@ -18,6 +17,5 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" coordinator: DataUpdateCoordinator[Vehicle] = hass.data[DOMAIN][entry.entry_id] - # Round-trip via JSON to trigger serialization - data: dict[str, Any] = json.loads(coordinator.data.json()) + data: dict[str, Any] = coordinator.data.to_dict() return data diff --git a/homeassistant/components/rdw/manifest.json b/homeassistant/components/rdw/manifest.json index bc8d3be8451..f44dc7e0f12 100644 --- a/homeassistant/components/rdw/manifest.json +++ b/homeassistant/components/rdw/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["vehicle==2.0.0"] + "requirements": ["vehicle==2.2.1"] } diff --git a/homeassistant/components/rdw/sensor.py b/homeassistant/components/rdw/sensor.py index f330ac16b8e..d25c23c09bd 100644 --- a/homeassistant/components/rdw/sensor.py +++ b/homeassistant/components/rdw/sensor.py @@ -24,20 +24,13 @@ from homeassistant.helpers.update_coordinator import ( from .const import CONF_LICENSE_PLATE, DOMAIN -@dataclass -class RDWSensorEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(kw_only=True) +class RDWSensorEntityDescription(SensorEntityDescription): + """Describes RDW sensor entity.""" value_fn: Callable[[Vehicle], date | str | float | None] -@dataclass -class RDWSensorEntityDescription( - SensorEntityDescription, RDWSensorEntityDescriptionMixin -): - """Describes RDW sensor entity.""" - - SENSORS: tuple[RDWSensorEntityDescription, ...] = ( RDWSensorEntityDescription( key="apk_expiration", diff --git a/homeassistant/components/recorder/auto_repairs/schema.py b/homeassistant/components/recorder/auto_repairs/schema.py index aa036f33999..aedf917dd22 100644 --- a/homeassistant/components/recorder/auto_repairs/schema.py +++ b/homeassistant/components/recorder/auto_repairs/schema.py @@ -101,9 +101,8 @@ def _validate_table_schema_has_correct_collation( collate = ( dialect_kwargs.get("mysql_collate") - or dialect_kwargs.get( - "mariadb_collate" - ) # pylint: disable-next=protected-access + or dialect_kwargs.get("mariadb_collate") + # pylint: disable-next=protected-access or connection.dialect._fetch_setting(connection, "collation_server") # type: ignore[attr-defined] ) if collate and collate != "utf8mb4_unicode_ci": diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 06c8cf68903..b864e104ae6 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -176,13 +176,17 @@ class NativeLargeBinary(LargeBinary): # For MariaDB and MySQL we can use an unsigned integer type since it will fit 2**32 # for sqlite and postgresql we use a bigint UINT_32_TYPE = BigInteger().with_variant( - mysql.INTEGER(unsigned=True), "mysql", "mariadb" # type: ignore[no-untyped-call] + mysql.INTEGER(unsigned=True), # type: ignore[no-untyped-call] + "mysql", + "mariadb", ) JSON_VARIANT_CAST = Text().with_variant( - postgresql.JSON(none_as_null=True), "postgresql" # type: ignore[no-untyped-call] + postgresql.JSON(none_as_null=True), # type: ignore[no-untyped-call] + "postgresql", ) JSONB_VARIANT_CAST = Text().with_variant( - postgresql.JSONB(none_as_null=True), "postgresql" # type: ignore[no-untyped-call] + postgresql.JSONB(none_as_null=True), # type: ignore[no-untyped-call] + "postgresql", ) DATETIME_TYPE = ( DateTime(timezone=True) diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index bf76c7264d5..fda8716df27 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -244,7 +244,8 @@ class Filters: ), # Needs https://github.com/bdraco/home-assistant/commit/bba91945006a46f3a01870008eb048e4f9cbb1ef self._generate_filter_for_columns( - (ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT), _encoder # type: ignore[arg-type] + (ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT), # type: ignore[arg-type] + _encoder, ).self_group(), ) diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 68c357c0ed4..da58822e266 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -527,31 +527,37 @@ def _get_start_time_state_for_entities_stmt( ) -> Select: """Baked query to get states for specific entities.""" # We got an include-list of entities, accelerate the query by filtering already - # in the inner query. - stmt = _stmt_and_join_attributes_for_start_state( - no_attributes, include_last_changed - ).join( - ( - most_recent_states_for_entities_by_date := ( - select( - States.metadata_id.label("max_metadata_id"), - func.max(States.last_updated_ts).label("max_last_updated"), + # in the inner and the outer query. + stmt = ( + _stmt_and_join_attributes_for_start_state(no_attributes, include_last_changed) + .join( + ( + most_recent_states_for_entities_by_date := ( + select( + States.metadata_id.label("max_metadata_id"), + func.max(States.last_updated_ts).label("max_last_updated"), + ) + .filter( + (States.last_updated_ts >= run_start_ts) + & (States.last_updated_ts < epoch_time) + & States.metadata_id.in_(metadata_ids) + ) + .group_by(States.metadata_id) + .subquery() ) - .filter( - (States.last_updated_ts >= run_start_ts) - & (States.last_updated_ts < epoch_time) - ) - .filter(States.metadata_id.in_(metadata_ids)) - .group_by(States.metadata_id) - .subquery() - ) - ), - and_( - States.metadata_id - == most_recent_states_for_entities_by_date.c.max_metadata_id, - States.last_updated_ts - == most_recent_states_for_entities_by_date.c.max_last_updated, - ), + ), + and_( + States.metadata_id + == most_recent_states_for_entities_by_date.c.max_metadata_id, + States.last_updated_ts + == most_recent_states_for_entities_by_date.c.max_last_updated, + ), + ) + .filter( + (States.last_updated_ts >= run_start_ts) + & (States.last_updated_ts < epoch_time) + & States.metadata_id.in_(metadata_ids) + ) ) if no_attributes: return stmt diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index f0e91071ea0..b630a71daff 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.22", + "SQLAlchemy==2.0.23", "fnv-hash-fast==0.5.0", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 8808ed2fd2b..427e3acab2d 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -532,7 +532,9 @@ def _update_states_table_with_foreign_key_options( states_key_constraints = Base.metadata.tables[TABLE_STATES].foreign_key_constraints old_states_table = Table( # noqa: F841 - TABLE_STATES, MetaData(), *(alter["old_fk"] for alter in alters) # type: ignore[arg-type] + TABLE_STATES, + MetaData(), + *(alter["old_fk"] for alter in alters), # type: ignore[arg-type] ) for alter in alters: diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 8bc6584c5a1..8dd539f84f3 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -41,7 +41,7 @@ from .queries import ( find_statistics_runs_to_purge, ) from .repack import repack_database -from .util import chunked, retryable_database_job, session_scope +from .util import chunked_or_all, retryable_database_job, session_scope if TYPE_CHECKING: from . import Recorder @@ -283,12 +283,16 @@ def _select_event_data_ids_to_purge( def _select_unused_attributes_ids( - session: Session, attributes_ids: set[int], database_engine: DatabaseEngine + instance: Recorder, + session: Session, + attributes_ids: set[int], + database_engine: DatabaseEngine, ) -> set[int]: """Return a set of attributes ids that are not used by any states in the db.""" if not attributes_ids: return set() + seen_ids: set[int] = set() if not database_engine.optimizer.slow_range_in_select: # # SQLite has a superior query optimizer for the distinct query below as it uses @@ -303,12 +307,17 @@ def _select_unused_attributes_ids( # (136723); # ...Using index # - seen_ids = { - state[0] - for state in session.execute( - attributes_ids_exist_in_states_with_fast_in_distinct(attributes_ids) - ).all() - } + for attributes_ids_chunk in chunked_or_all( + attributes_ids, instance.max_bind_vars + ): + seen_ids.update( + state[0] + for state in session.execute( + attributes_ids_exist_in_states_with_fast_in_distinct( + attributes_ids_chunk + ) + ).all() + ) else: # # This branch is for DBMS that cannot optimize the distinct query well and has @@ -334,7 +343,6 @@ def _select_unused_attributes_ids( # We now break the query into groups of 100 and use a lambda_stmt to ensure # that the query is only cached once. # - seen_ids = set() groups = [iter(attributes_ids)] * 100 for attr_ids in zip_longest(*groups, fillvalue=None): seen_ids |= { @@ -361,29 +369,33 @@ def _purge_unused_attributes_ids( database_engine = instance.database_engine assert database_engine is not None if unused_attribute_ids_set := _select_unused_attributes_ids( - session, attributes_ids_batch, database_engine + instance, session, attributes_ids_batch, database_engine ): _purge_batch_attributes_ids(instance, session, unused_attribute_ids_set) def _select_unused_event_data_ids( - session: Session, data_ids: set[int], database_engine: DatabaseEngine + instance: Recorder, + session: Session, + data_ids: set[int], + database_engine: DatabaseEngine, ) -> set[int]: """Return a set of event data ids that are not used by any events in the db.""" if not data_ids: return set() + seen_ids: set[int] = set() # See _select_unused_attributes_ids for why this function # branches for non-sqlite databases. if not database_engine.optimizer.slow_range_in_select: - seen_ids = { - state[0] - for state in session.execute( - data_ids_exist_in_events_with_fast_in_distinct(data_ids) - ).all() - } + for data_ids_chunk in chunked_or_all(data_ids, instance.max_bind_vars): + seen_ids.update( + state[0] + for state in session.execute( + data_ids_exist_in_events_with_fast_in_distinct(data_ids_chunk) + ).all() + ) else: - seen_ids = set() groups = [iter(data_ids)] * 100 for data_ids_group in zip_longest(*groups, fillvalue=None): seen_ids |= { @@ -404,7 +416,7 @@ def _purge_unused_data_ids( database_engine = instance.database_engine assert database_engine is not None if unused_data_ids_set := _select_unused_event_data_ids( - session, data_ids_batch, database_engine + instance, session, data_ids_batch, database_engine ): _purge_batch_data_ids(instance, session, unused_data_ids_set) @@ -519,7 +531,7 @@ def _purge_batch_attributes_ids( instance: Recorder, session: Session, attributes_ids: set[int] ) -> None: """Delete old attributes ids in batches of max_bind_vars.""" - for attributes_ids_chunk in chunked(attributes_ids, instance.max_bind_vars): + for attributes_ids_chunk in chunked_or_all(attributes_ids, instance.max_bind_vars): deleted_rows = session.execute( delete_states_attributes_rows(attributes_ids_chunk) ) @@ -533,7 +545,7 @@ def _purge_batch_data_ids( instance: Recorder, session: Session, data_ids: set[int] ) -> None: """Delete old event data ids in batches of max_bind_vars.""" - for data_ids_chunk in chunked(data_ids, instance.max_bind_vars): + for data_ids_chunk in chunked_or_all(data_ids, instance.max_bind_vars): deleted_rows = session.execute(delete_event_data_rows(data_ids_chunk)) _LOGGER.debug("Deleted %s data events", deleted_rows) @@ -694,7 +706,10 @@ def _purge_filtered_states( # we will need to purge them here. _purge_event_ids(session, filtered_event_ids) unused_attribute_ids_set = _select_unused_attributes_ids( - session, {id_ for id_ in attributes_ids if id_ is not None}, database_engine + instance, + session, + {id_ for id_ in attributes_ids if id_ is not None}, + database_engine, ) _purge_batch_attributes_ids(instance, session, unused_attribute_ids_set) return False @@ -741,7 +756,7 @@ def _purge_filtered_events( _purge_state_ids(instance, session, state_ids) _purge_event_ids(session, event_ids_set) if unused_data_ids_set := _select_unused_event_data_ids( - session, set(data_ids), database_engine + instance, session, set(data_ids), database_engine ): _purge_batch_data_ids(instance, session, unused_data_ids_set) return False diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index a6fe7ddb22f..78c475753a2 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -1088,10 +1088,7 @@ def _generate_statistics_during_period_stmt( end_time_ts = end_time.timestamp() stmt += lambda q: q.filter(table.start_ts < end_time_ts) if metadata_ids: - stmt += lambda q: q.filter( - # https://github.com/python/mypy/issues/2608 - table.metadata_id.in_(metadata_ids) # type:ignore[arg-type] - ) + stmt += lambda q: q.filter(table.metadata_id.in_(metadata_ids)) stmt += lambda q: q.order_by(table.metadata_id, table.start_ts) return stmt diff --git a/homeassistant/components/recorder/table_managers/statistics_meta.py b/homeassistant/components/recorder/table_managers/statistics_meta.py index 75af59d7c7a..a484bdf145e 100644 --- a/homeassistant/components/recorder/table_managers/statistics_meta.py +++ b/homeassistant/components/recorder/table_managers/statistics_meta.py @@ -41,10 +41,7 @@ def _generate_get_metadata_stmt( """Generate a statement to fetch metadata.""" stmt = lambda_stmt(lambda: select(*QUERY_STATISTIC_META)) if statistic_ids: - stmt += lambda q: q.where( - # https://github.com/python/mypy/issues/2608 - StatisticsMeta.statistic_id.in_(statistic_ids) # type:ignore[arg-type] - ) + stmt += lambda q: q.where(StatisticsMeta.statistic_id.in_(statistic_ids)) if statistic_source is not None: stmt += lambda q: q.where(StatisticsMeta.source == statistic_source) if statistic_type == "mean": diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index f94601bb2cb..2d518d8874b 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -1,7 +1,7 @@ """SQLAlchemy util functions.""" from __future__ import annotations -from collections.abc import Callable, Generator, Iterable, Sequence +from collections.abc import Callable, Collection, Generator, Iterable, Sequence from contextlib import contextmanager from datetime import date, datetime, timedelta import functools @@ -857,6 +857,20 @@ def chunked(iterable: Iterable, chunked_num: int) -> Iterable[Any]: return iter(partial(take, chunked_num, iter(iterable)), []) +def chunked_or_all(iterable: Collection[Any], chunked_num: int) -> Iterable[Any]: + """Break *collection* into iterables of length *n*. + + Returns the collection if its length is less than *n*. + + Unlike chunked, this function requires a collection so it can + determine the length of the collection and return the collection + if it is less than *n*. + """ + if len(iterable) <= chunked_num: + return (iterable,) + return chunked(iterable, chunked_num) + + def get_index_by_name(session: Session, table_name: str, index_name: str) -> str | None: """Get an index by name.""" connection = session.connection() diff --git a/homeassistant/components/renault/coordinator.py b/homeassistant/components/renault/coordinator.py index d101b551dfe..f8e6a21823a 100644 --- a/homeassistant/components/renault/coordinator.py +++ b/homeassistant/components/renault/coordinator.py @@ -45,6 +45,7 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): ) self.access_denied = False self.not_supported = False + self._has_already_worked = False async def _async_update_data(self) -> T: """Fetch the latest data from the source.""" @@ -52,11 +53,16 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): raise NotImplementedError("Update method not implemented") try: async with _PARALLEL_SEMAPHORE: - return await self.update_method() + data = await self.update_method() + self._has_already_worked = True + return data + except AccessDeniedException as err: - # Disable because the account is not allowed to access this Renault endpoint. - self.update_interval = None - self.access_denied = True + # This can mean both a temporary error or a permanent error. If it has + # worked before, make it temporary, if not disable the update interval. + if not self._has_already_worked: + self.update_interval = None + self.access_denied = True raise UpdateFailed(f"This endpoint is denied: {err}") from err except NotSupportedException as err: diff --git a/homeassistant/components/renson/button.py b/homeassistant/components/renson/button.py index 53d995ba792..a91a057e0e7 100644 --- a/homeassistant/components/renson/button.py +++ b/homeassistant/components/renson/button.py @@ -48,6 +48,12 @@ ENTITY_DESCRIPTIONS: tuple[RensonButtonEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, action_fn=lambda api: api.restart_device(), ), + RensonButtonEntityDescription( + key="reset_filter", + translation_key="reset_filter", + entity_category=EntityCategory.CONFIG, + action_fn=lambda api: api.reset_filter(), + ), ) diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index 7099cdf2c45..8aa7c6244ea 100644 --- a/homeassistant/components/renson/strings.json +++ b/homeassistant/components/renson/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Renson Endura delta device." } } }, @@ -16,6 +19,9 @@ "button": { "sync_time": { "name": "Sync time with device" + }, + "reset_filter": { + "name": "Reset filter counter" } }, "number": { diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 8425f29fbe8..46761beae00 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -89,9 +89,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): await host.renew() - async def async_check_firmware_update() -> str | Literal[ - False - ] | NewSoftwareVersion: + async def async_check_firmware_update() -> ( + str | Literal[False] | NewSoftwareVersion + ): """Check for firmware updates.""" if not host.api.supported(None, "update"): return False diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 7f2ff3e0053..e2e8e6b24f9 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -28,22 +28,14 @@ from .const import DOMAIN from .entity import ReolinkChannelCoordinatorEntity -@dataclass -class ReolinkBinarySensorEntityDescriptionMixin: - """Mixin values for Reolink binary sensor entities.""" - - value: Callable[[Host, int], bool] - - -@dataclass -class ReolinkBinarySensorEntityDescription( - BinarySensorEntityDescription, ReolinkBinarySensorEntityDescriptionMixin -): +@dataclass(kw_only=True) +class ReolinkBinarySensorEntityDescription(BinarySensorEntityDescription): """A class that describes binary sensor entities.""" - icon: str = "mdi:motion-sensor" icon_off: str = "mdi:motion-sensor-off" + icon: str = "mdi:motion-sensor" supported: Callable[[Host, int], bool] = lambda host, ch: True + value: Callable[[Host, int], bool] BINARY_SENSORS = ( @@ -79,7 +71,18 @@ BINARY_SENSORS = ( icon="mdi:dog-side", icon_off="mdi:dog-side-off", value=lambda api, ch: api.ai_detected(ch, PET_DETECTION_TYPE), - supported=lambda api, ch: api.ai_supported(ch, PET_DETECTION_TYPE), + supported=lambda api, ch: ( + api.ai_supported(ch, PET_DETECTION_TYPE) + and not api.supported(ch, "ai_animal") + ), + ), + ReolinkBinarySensorEntityDescription( + key=PET_DETECTION_TYPE, + translation_key="animal", + icon="mdi:paw", + icon_off="mdi:paw-off", + value=lambda api, ch: api.ai_detected(ch, PET_DETECTION_TYPE), + supported=lambda api, ch: api.supported(ch, "ai_animal"), ), ReolinkBinarySensorEntityDescription( key="visitor", diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index f1797527914..6e9c9c2e386 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -6,52 +6,50 @@ from dataclasses import dataclass from typing import Any from reolink_aio.api import GuardEnum, Host, PtzEnum +from reolink_aio.exceptions import ReolinkError +import voluptuous as vol from homeassistant.components.button import ( ButtonDeviceClass, ButtonEntity, ButtonEntityDescription, ) +from homeassistant.components.camera import CameraEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from . import ReolinkData from .const import DOMAIN from .entity import ReolinkChannelCoordinatorEntity, ReolinkHostCoordinatorEntity - -@dataclass -class ReolinkButtonEntityDescriptionMixin: - """Mixin values for Reolink button entities for a camera channel.""" - - method: Callable[[Host, int], Any] +ATTR_SPEED = "speed" +SUPPORT_PTZ_SPEED = CameraEntityFeature.STREAM -@dataclass +@dataclass(kw_only=True) class ReolinkButtonEntityDescription( - ButtonEntityDescription, ReolinkButtonEntityDescriptionMixin + ButtonEntityDescription, ): """A class that describes button entities for a camera channel.""" - supported: Callable[[Host, int], bool] = lambda api, ch: True enabled_default: Callable[[Host, int], bool] | None = None + method: Callable[[Host, int], Any] + supported: Callable[[Host, int], bool] = lambda api, ch: True + ptz_cmd: str | None = None -@dataclass -class ReolinkHostButtonEntityDescriptionMixin: - """Mixin values for Reolink button entities for the host.""" - - method: Callable[[Host], Any] - - -@dataclass -class ReolinkHostButtonEntityDescription( - ButtonEntityDescription, ReolinkHostButtonEntityDescriptionMixin -): +@dataclass(kw_only=True) +class ReolinkHostButtonEntityDescription(ButtonEntityDescription): """A class that describes button entities for the host.""" + method: Callable[[Host], Any] supported: Callable[[Host], bool] = lambda api: True @@ -61,8 +59,9 @@ BUTTON_ENTITIES = ( translation_key="ptz_stop", icon="mdi:pan", enabled_default=lambda api, ch: api.supported(ch, "pan_tilt"), - supported=lambda api, ch: api.supported(ch, "pan_tilt") - or api.supported(ch, "zoom_basic"), + supported=lambda api, ch: ( + api.supported(ch, "pan_tilt") or api.supported(ch, "zoom_basic") + ), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.stop.value), ), ReolinkButtonEntityDescription( @@ -71,6 +70,7 @@ BUTTON_ENTITIES = ( icon="mdi:pan", supported=lambda api, ch: api.supported(ch, "pan"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.left.value), + ptz_cmd=PtzEnum.left.value, ), ReolinkButtonEntityDescription( key="ptz_right", @@ -78,6 +78,7 @@ BUTTON_ENTITIES = ( icon="mdi:pan", supported=lambda api, ch: api.supported(ch, "pan"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.right.value), + ptz_cmd=PtzEnum.right.value, ), ReolinkButtonEntityDescription( key="ptz_up", @@ -85,6 +86,7 @@ BUTTON_ENTITIES = ( icon="mdi:pan", supported=lambda api, ch: api.supported(ch, "tilt"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.up.value), + ptz_cmd=PtzEnum.up.value, ), ReolinkButtonEntityDescription( key="ptz_down", @@ -92,6 +94,7 @@ BUTTON_ENTITIES = ( icon="mdi:pan", supported=lambda api, ch: api.supported(ch, "tilt"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.down.value), + ptz_cmd=PtzEnum.down.value, ), ReolinkButtonEntityDescription( key="ptz_zoom_in", @@ -100,6 +103,7 @@ BUTTON_ENTITIES = ( entity_registry_enabled_default=False, supported=lambda api, ch: api.supported(ch, "zoom_basic"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.zoomin.value), + ptz_cmd=PtzEnum.zoomin.value, ), ReolinkButtonEntityDescription( key="ptz_zoom_out", @@ -108,6 +112,7 @@ BUTTON_ENTITIES = ( entity_registry_enabled_default=False, supported=lambda api, ch: api.supported(ch, "zoom_basic"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.zoomout.value), + ptz_cmd=PtzEnum.zoomout.value, ), ReolinkButtonEntityDescription( key="ptz_calibrate", @@ -169,6 +174,14 @@ async def async_setup_entry( ) async_add_entities(entities) + platform = async_get_current_platform() + platform.async_register_entity_service( + "ptz_move", + {vol.Required(ATTR_SPEED): cv.positive_int}, + "async_ptz_move", + [SUPPORT_PTZ_SPEED], + ) + class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity): """Base button entity class for Reolink IP cameras.""" @@ -193,9 +206,28 @@ class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity): entity_description.enabled_default(self._host.api, self._channel) ) + if ( + self._host.api.supported(channel, "ptz_speed") + and entity_description.ptz_cmd is not None + ): + self._attr_supported_features = SUPPORT_PTZ_SPEED + async def async_press(self) -> None: """Execute the button action.""" - await self.entity_description.method(self._host.api, self._channel) + try: + await self.entity_description.method(self._host.api, self._channel) + except ReolinkError as err: + raise HomeAssistantError(err) from err + + async def async_ptz_move(self, **kwargs) -> None: + """PTZ move with speed.""" + speed = kwargs[ATTR_SPEED] + try: + await self._host.api.set_ptz_command( + self._channel, command=self.entity_description.ptz_cmd, speed=speed + ) + except ReolinkError as err: + raise HomeAssistantError(err) from err class ReolinkHostButtonEntity(ReolinkHostCoordinatorEntity, ButtonEntity): @@ -216,4 +248,7 @@ class ReolinkHostButtonEntity(ReolinkHostCoordinatorEntity, ButtonEntity): async def async_press(self) -> None: """Execute the button action.""" - await self.entity_description.method(self._host.api) + try: + await self.entity_description.method(self._host.api) + except ReolinkError as err: + raise HomeAssistantError(err) from err diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index b012649ec4c..ea9b84cd53f 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -1,13 +1,21 @@ """Component providing support for Reolink IP cameras.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass import logging -from reolink_aio.api import DUAL_LENS_MODELS +from reolink_aio.api import DUAL_LENS_MODELS, Host +from reolink_aio.exceptions import ReolinkError -from homeassistant.components.camera import Camera, CameraEntityFeature +from homeassistant.components.camera import ( + Camera, + CameraEntityDescription, + CameraEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData @@ -17,6 +25,70 @@ from .entity import ReolinkChannelCoordinatorEntity _LOGGER = logging.getLogger(__name__) +@dataclass(kw_only=True) +class ReolinkCameraEntityDescription( + CameraEntityDescription, +): + """A class that describes camera entities for a camera channel.""" + + stream: str + supported: Callable[[Host, int], bool] = lambda api, ch: True + + +CAMERA_ENTITIES = ( + ReolinkCameraEntityDescription( + key="sub", + stream="sub", + translation_key="sub", + ), + ReolinkCameraEntityDescription( + key="main", + stream="main", + translation_key="main", + entity_registry_enabled_default=False, + ), + ReolinkCameraEntityDescription( + key="snapshots_sub", + stream="snapshots_sub", + translation_key="snapshots_sub", + entity_registry_enabled_default=False, + ), + ReolinkCameraEntityDescription( + key="snapshots", + stream="snapshots_main", + translation_key="snapshots_main", + entity_registry_enabled_default=False, + ), + ReolinkCameraEntityDescription( + key="ext", + stream="ext", + translation_key="ext", + supported=lambda api, ch: api.protocol in ["rtmp", "flv"], + entity_registry_enabled_default=False, + ), + ReolinkCameraEntityDescription( + key="autotrack_sub", + stream="autotrack_sub", + translation_key="autotrack_sub", + supported=lambda api, ch: api.supported(ch, "autotrack_stream"), + ), + ReolinkCameraEntityDescription( + key="autotrack_snapshots_sub", + stream="autotrack_snapshots_sub", + translation_key="autotrack_snapshots_sub", + supported=lambda api, ch: api.supported(ch, "autotrack_stream"), + entity_registry_enabled_default=False, + ), + ReolinkCameraEntityDescription( + key="autotrack_snapshots_main", + stream="autotrack_snapshots_main", + translation_key="autotrack_snapshots_main", + supported=lambda api, ch: api.supported(ch, "autotrack_stream"), + entity_registry_enabled_default=False, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -24,62 +96,62 @@ async def async_setup_entry( ) -> None: """Set up a Reolink IP Camera.""" reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] - host = reolink_data.host - cameras = [] - for channel in host.api.stream_channels: - streams = ["sub", "main", "snapshots_sub", "snapshots_main"] - if host.api.protocol in ["rtmp", "flv"]: - streams.append("ext") - - if host.api.supported(channel, "autotrack_stream"): - streams.extend( - ["autotrack_sub", "autotrack_snapshots_sub", "autotrack_snapshots_main"] - ) - - for stream in streams: - stream_url = await host.api.get_stream_source(channel, stream) - if stream_url is None and "snapshots" not in stream: + entities: list[ReolinkCamera] = [] + for entity_description in CAMERA_ENTITIES: + for channel in reolink_data.host.api.stream_channels: + if not entity_description.supported(reolink_data.host.api, channel): + continue + stream_url = await reolink_data.host.api.get_stream_source( + channel, entity_description.stream + ) + if stream_url is None and "snapshots" not in entity_description.stream: continue - cameras.append(ReolinkCamera(reolink_data, channel, stream)) - async_add_entities(cameras) + entities.append(ReolinkCamera(reolink_data, channel, entity_description)) + + async_add_entities(entities) class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera): """An implementation of a Reolink IP camera.""" _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM + entity_description: ReolinkCameraEntityDescription def __init__( self, reolink_data: ReolinkData, channel: int, - stream: str, + entity_description: ReolinkCameraEntityDescription, ) -> None: """Initialize Reolink camera stream.""" + self.entity_description = entity_description ReolinkChannelCoordinatorEntity.__init__(self, reolink_data, channel) Camera.__init__(self) - self._stream = stream - - stream_name = self._stream.replace("_", " ") if self._host.api.model in DUAL_LENS_MODELS: - self._attr_name = f"{stream_name} lens {self._channel}" - else: - self._attr_name = stream_name - stream_id = self._stream - if stream_id == "snapshots_main": - stream_id = "snapshots" - self._attr_unique_id = f"{self._host.unique_id}_{self._channel}_{stream_id}" - self._attr_entity_registry_enabled_default = stream in ["sub", "autotrack_sub"] + self._attr_translation_key = ( + f"{entity_description.translation_key}_lens_{self._channel}" + ) + + self._attr_unique_id = ( + f"{self._host.unique_id}_{channel}_{entity_description.key}" + ) async def stream_source(self) -> str | None: """Return the source of the stream.""" - return await self._host.api.get_stream_source(self._channel, self._stream) + return await self._host.api.get_stream_source( + self._channel, self.entity_description.stream + ) async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" - return await self._host.api.get_snapshot(self._channel, self._stream) + try: + return await self._host.api.get_snapshot( + self._channel, self.entity_description.stream + ) + except ReolinkError as err: + raise HomeAssistantError(err) from err diff --git a/homeassistant/components/reolink/diagnostics.py b/homeassistant/components/reolink/diagnostics.py new file mode 100644 index 00000000000..04b476296f8 --- /dev/null +++ b/homeassistant/components/reolink/diagnostics.py @@ -0,0 +1,46 @@ +"""Diagnostics support for Reolink.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from . import ReolinkData +from .const import DOMAIN + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + host = reolink_data.host + api = host.api + + IPC_cam: dict[int, dict[str, Any]] = {} + for ch in api.channels: + IPC_cam[ch] = {} + IPC_cam[ch]["model"] = api.camera_model(ch) + IPC_cam[ch]["firmware version"] = api.camera_sw_version(ch) + + return { + "model": api.model, + "hardware version": api.hardware_version, + "firmware version": api.sw_version, + "HTTPS": api.use_https, + "HTTP(S) port": api.port, + "WiFi connection": api.wifi_connection, + "WiFi signal": api.wifi_signal, + "RTMP enabled": api.rtmp_enabled, + "RTSP enabled": api.rtsp_enabled, + "ONVIF enabled": api.onvif_enabled, + "event connection": host.event_connection, + "stream protocol": api.protocol, + "channels": api.channels, + "stream channels": api.stream_channels, + "IPC cams": IPC_cam, + "capabilities": api.capabilities, + "api versions": api.checked_api_versions, + "abilities": api.abilities, + } diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index e7d62c9705a..5c874fb7ff9 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -42,6 +42,7 @@ class ReolinkBaseCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[_T]]) manufacturer=self._host.api.manufacturer, hw_version=self._host.api.hardware_version, sw_version=self._host.api.sw_version, + serial_number=self._host.api.uid, configuration_url=self._conf_url, ) @@ -87,5 +88,6 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): name=self._host.api.camera_name(dev_ch), model=self._host.api.camera_model(dev_ch), manufacturer=self._host.api.manufacturer, + sw_version=self._host.api.camera_sw_version(dev_ch), configuration_url=self._conf_url, ) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 0075bbac4e6..11cf8f665ad 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -163,7 +163,7 @@ class ReolinkHost: if self._onvif_push_supported: try: await self.subscribe() - except NotSupportedError: + except ReolinkError: self._onvif_push_supported = False self.unregister_webhook() await self._api.unsubscribe() @@ -661,3 +661,12 @@ class ReolinkHost: for channel in channels: async_dispatcher_send(self._hass, f"{self.webhook_id}_{channel}", {}) + + @property + def event_connection(self) -> str: + """Type of connection to receive events.""" + if self._webhook_reachable: + return "ONVIF push" + if self._long_poll_received: + return "ONVIF long polling" + return "Fast polling" diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index 938093df4a3..f1aa0cb9ee2 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from typing import Any from reolink_aio.api import Host +from reolink_aio.exceptions import InvalidParameterError, ReolinkError from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -16,6 +17,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData @@ -23,23 +25,15 @@ from .const import DOMAIN from .entity import ReolinkChannelCoordinatorEntity -@dataclass -class ReolinkLightEntityDescriptionMixin: - """Mixin values for Reolink light entities.""" - - is_on_fn: Callable[[Host, int], bool] - turn_on_off_fn: Callable[[Host, int, bool], Any] - - -@dataclass -class ReolinkLightEntityDescription( - LightEntityDescription, ReolinkLightEntityDescriptionMixin -): +@dataclass(kw_only=True) +class ReolinkLightEntityDescription(LightEntityDescription): """A class that describes light entities.""" - supported_fn: Callable[[Host, int], bool] = lambda api, ch: True get_brightness_fn: Callable[[Host, int], int | None] | None = None + is_on_fn: Callable[[Host, int], bool] set_brightness_fn: Callable[[Host, int, int], Any] | None = None + supported_fn: Callable[[Host, int], bool] = lambda api, ch: True + turn_on_off_fn: Callable[[Host, int, bool], Any] LIGHT_ENTITIES = ( @@ -137,9 +131,12 @@ class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn light off.""" - await self.entity_description.turn_on_off_fn( - self._host.api, self._channel, False - ) + try: + await self.entity_description.turn_on_off_fn( + self._host.api, self._channel, False + ) + except ReolinkError as err: + raise HomeAssistantError(err) from err self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: @@ -148,11 +145,19 @@ class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity): brightness := kwargs.get(ATTR_BRIGHTNESS) ) is not None and self.entity_description.set_brightness_fn is not None: brightness_pct = int(brightness / 255.0 * 100) - await self.entity_description.set_brightness_fn( - self._host.api, self._channel, brightness_pct - ) + try: + await self.entity_description.set_brightness_fn( + self._host.api, self._channel, brightness_pct + ) + except InvalidParameterError as err: + raise ServiceValidationError(err) from err + except ReolinkError as err: + raise HomeAssistantError(err) from err - await self.entity_description.turn_on_off_fn( - self._host.api, self._channel, True - ) + try: + await self.entity_description.turn_on_off_fn( + self._host.api, self._channel, True + ) + except ReolinkError as err: + raise HomeAssistantError(err) from err self.async_write_ha_state() diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 9189de89efa..5ffbc2fb186 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.7.14"] + "requirements": ["reolink-aio==0.8.1"] } diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py new file mode 100644 index 00000000000..6a350e13836 --- /dev/null +++ b/homeassistant/components/reolink/media_source.py @@ -0,0 +1,330 @@ +"""Expose Reolink IP camera VODs as media sources.""" + +from __future__ import annotations + +import datetime as dt +import logging + +from homeassistant.components.camera import DOMAIN as CAM_DOMAIN, DynamicStreamSettings +from homeassistant.components.media_player import MediaClass, MediaType +from homeassistant.components.media_source.error import Unresolvable +from homeassistant.components.media_source.models import ( + BrowseMediaSource, + MediaSource, + MediaSourceItem, + PlayMedia, +) +from homeassistant.components.stream import create_stream +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import ReolinkData +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_media_source(hass: HomeAssistant) -> ReolinkVODMediaSource: + """Set up camera media source.""" + return ReolinkVODMediaSource(hass) + + +def res_name(stream: str) -> str: + """Return the user friendly name for a stream.""" + return "High res." if stream == "main" else "Low res." + + +class ReolinkVODMediaSource(MediaSource): + """Provide Reolink camera VODs as media sources.""" + + name: str = "Reolink" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize ReolinkVODMediaSource.""" + super().__init__(DOMAIN) + self.hass = hass + self.data: dict[str, ReolinkData] = hass.data[DOMAIN] + + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: + """Resolve media to a url.""" + identifier = item.identifier.split("|", 5) + if identifier[0] != "FILE": + raise Unresolvable(f"Unknown media item '{item.identifier}'.") + + _, config_entry_id, channel_str, stream_res, filename = identifier + channel = int(channel_str) + + host = self.data[config_entry_id].host + mime_type, url = await host.api.get_vod_source(channel, filename, stream_res) + if _LOGGER.isEnabledFor(logging.DEBUG): + url_log = f"{url.split('&user=')[0]}&user=xxxxx&password=xxxxx" + _LOGGER.debug( + "Opening VOD stream from %s: %s", host.api.camera_name(channel), url_log + ) + + stream = create_stream(self.hass, url, {}, DynamicStreamSettings()) + stream.add_provider("hls", timeout=3600) + stream_url: str = stream.endpoint_url("hls") + stream_url = stream_url.replace("master_", "") + return PlayMedia(stream_url, mime_type) + + async def async_browse_media( + self, + item: MediaSourceItem, + ) -> BrowseMediaSource: + """Return media.""" + if item.identifier is None: + return await self._async_generate_root() + + identifier = item.identifier.split("|", 7) + item_type = identifier[0] + + if item_type == "CAM": + _, config_entry_id, channel_str = identifier + return await self._async_generate_resolution_select( + config_entry_id, int(channel_str) + ) + if item_type == "RES": + _, config_entry_id, channel_str, stream = identifier + return await self._async_generate_camera_days( + config_entry_id, int(channel_str), stream + ) + if item_type == "DAY": + ( + _, + config_entry_id, + channel_str, + stream, + year_str, + month_str, + day_str, + ) = identifier + return await self._async_generate_camera_files( + config_entry_id, + int(channel_str), + stream, + int(year_str), + int(month_str), + int(day_str), + ) + + raise Unresolvable(f"Unknown media item '{item.identifier}' during browsing.") + + async def _async_generate_root(self) -> BrowseMediaSource: + """Return all available reolink cameras as root browsing structure.""" + children: list[BrowseMediaSource] = [] + + entity_reg = er.async_get(self.hass) + device_reg = dr.async_get(self.hass) + for config_entry in self.hass.config_entries.async_entries(DOMAIN): + if config_entry.state != ConfigEntryState.LOADED: + continue + channels: list[str] = [] + host = self.data[config_entry.entry_id].host + entities = er.async_entries_for_config_entry( + entity_reg, config_entry.entry_id + ) + for entity in entities: + if ( + entity.disabled + or entity.device_id is None + or entity.domain != CAM_DOMAIN + ): + continue + + device = device_reg.async_get(entity.device_id) + ch = entity.unique_id.split("_")[1] + if ch in channels or device is None: + continue + channels.append(ch) + + if ( + host.api.api_version("recReplay", int(ch)) < 1 + or not host.api.hdd_info + ): + # playback stream not supported by this camera or no storage installed + continue + + device_name = device.name + if device.name_by_user is not None: + device_name = device.name_by_user + + children.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=f"CAM|{config_entry.entry_id}|{ch}", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title=device_name, + thumbnail=f"/api/camera_proxy/{entity.entity_id}", + can_play=False, + can_expand=True, + ) + ) + + return BrowseMediaSource( + domain=DOMAIN, + identifier=None, + media_class=MediaClass.APP, + media_content_type="", + title="Reolink", + can_play=False, + can_expand=True, + children=children, + ) + + async def _async_generate_resolution_select( + self, config_entry_id: str, channel: int + ) -> BrowseMediaSource: + """Allow the user to select the high or low playback resolution, (low loads faster).""" + host = self.data[config_entry_id].host + + main_enc = await host.api.get_encoding(channel, "main") + if main_enc == "h265": + _LOGGER.debug( + "Reolink camera %s uses h265 encoding for main stream," + "playback only possible using sub stream", + host.api.camera_name(channel), + ) + return await self._async_generate_camera_days( + config_entry_id, channel, "sub" + ) + + children = [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"RES|{config_entry_id}|{channel}|sub", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title="Low resolution", + can_play=False, + can_expand=True, + ), + BrowseMediaSource( + domain=DOMAIN, + identifier=f"RES|{config_entry_id}|{channel}|main", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title="High resolution", + can_play=False, + can_expand=True, + ), + ] + + return BrowseMediaSource( + domain=DOMAIN, + identifier=f"RESs|{config_entry_id}|{channel}", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title=host.api.camera_name(channel), + can_play=False, + can_expand=True, + children=children, + ) + + async def _async_generate_camera_days( + self, config_entry_id: str, channel: int, stream: str + ) -> BrowseMediaSource: + """Return all days on which recordings are available for a reolink camera.""" + host = self.data[config_entry_id].host + + # We want today of the camera, not necessarily today of the server + now = host.api.time() or await host.api.async_get_time() + start = now - dt.timedelta(days=31) + end = now + + children: list[BrowseMediaSource] = [] + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Requesting recording days of %s from %s to %s", + host.api.camera_name(channel), + start, + end, + ) + statuses, _ = await host.api.request_vod_files( + channel, start, end, status_only=True, stream=stream + ) + for status in statuses: + for day in status.days: + children.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=f"DAY|{config_entry_id}|{channel}|{stream}|{status.year}|{status.month}|{day}", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaType.PLAYLIST, + title=f"{status.year}/{status.month}/{day}", + can_play=False, + can_expand=True, + ) + ) + + return BrowseMediaSource( + domain=DOMAIN, + identifier=f"DAYS|{config_entry_id}|{channel}|{stream}", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title=f"{host.api.camera_name(channel)} {res_name(stream)}", + can_play=False, + can_expand=True, + children=children, + ) + + async def _async_generate_camera_files( + self, + config_entry_id: str, + channel: int, + stream: str, + year: int, + month: int, + day: int, + ) -> BrowseMediaSource: + """Return all recording files on a specific day of a Reolink camera.""" + host = self.data[config_entry_id].host + + start = dt.datetime(year, month, day, hour=0, minute=0, second=0) + end = dt.datetime(year, month, day, hour=23, minute=59, second=59) + + children: list[BrowseMediaSource] = [] + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Requesting VODs of %s on %s/%s/%s", + host.api.camera_name(channel), + year, + month, + day, + ) + _, vod_files = await host.api.request_vod_files( + channel, start, end, stream=stream + ) + for file in vod_files: + file_name = f"{file.start_time.time()} {file.duration}" + if file.triggers != file.triggers.NONE: + file_name += " " + " ".join( + str(trigger.name).title() + for trigger in file.triggers + if trigger != trigger.NONE + ) + + children.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=f"FILE|{config_entry_id}|{channel}|{stream}|{file.file_name}", + media_class=MediaClass.VIDEO, + media_content_type=MediaType.VIDEO, + title=file_name, + can_play=True, + can_expand=False, + ) + ) + + return BrowseMediaSource( + domain=DOMAIN, + identifier=f"FILES|{config_entry_id}|{channel}|{stream}", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title=f"{host.api.camera_name(channel)} {res_name(stream)} {year}/{month}/{day}", + can_play=False, + can_expand=True, + children=children, + ) diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 6be0cef1670..1780465850a 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from typing import Any from reolink_aio.api import Host +from reolink_aio.exceptions import InvalidParameterError, ReolinkError from homeassistant.components.number import ( NumberEntity, @@ -15,6 +16,7 @@ from homeassistant.components.number import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData @@ -22,24 +24,16 @@ from .const import DOMAIN from .entity import ReolinkChannelCoordinatorEntity -@dataclass -class ReolinkNumberEntityDescriptionMixin: - """Mixin values for Reolink number entities.""" - - value: Callable[[Host, int], float | None] - method: Callable[[Host, int, float], Any] - - -@dataclass -class ReolinkNumberEntityDescription( - NumberEntityDescription, ReolinkNumberEntityDescriptionMixin -): +@dataclass(kw_only=True) +class ReolinkNumberEntityDescription(NumberEntityDescription): """A class that describes number entities.""" + get_max_value: Callable[[Host, int], float] | None = None + get_min_value: Callable[[Host, int], float] | None = None + method: Callable[[Host, int, float], Any] mode: NumberMode = NumberMode.AUTO supported: Callable[[Host, int], bool] = lambda api, ch: True - get_min_value: Callable[[Host, int], float] | None = None - get_max_value: Callable[[Host, int], float] | None = None + value: Callable[[Host, int], float | None] NUMBER_ENTITIES = ( @@ -170,7 +164,23 @@ NUMBER_ENTITIES = ( native_min_value=0, native_max_value=100, supported=lambda api, ch: ( - api.supported(ch, "ai_sensitivity") and api.ai_supported(ch, "dog_cat") + api.supported(ch, "ai_sensitivity") + and api.ai_supported(ch, "dog_cat") + and not api.supported(ch, "ai_animal") + ), + value=lambda api, ch: api.ai_sensitivity(ch, "dog_cat"), + method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "dog_cat"), + ), + ReolinkNumberEntityDescription( + key="ai_pet_sensititvity", + translation_key="ai_animal_sensititvity", + icon="mdi:paw", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: ( + api.supported(ch, "ai_sensitivity") and api.supported(ch, "ai_animal") ), value=lambda api, ch: api.ai_sensitivity(ch, "dog_cat"), method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "dog_cat"), @@ -234,7 +244,25 @@ NUMBER_ENTITIES = ( native_min_value=0, native_max_value=8, supported=lambda api, ch: ( - api.supported(ch, "ai_delay") and api.ai_supported(ch, "dog_cat") + api.supported(ch, "ai_delay") + and api.ai_supported(ch, "dog_cat") + and not api.supported(ch, "ai_animal") + ), + value=lambda api, ch: api.ai_delay(ch, "dog_cat"), + method=lambda api, ch, value: api.set_ai_delay(ch, int(value), "dog_cat"), + ), + ReolinkNumberEntityDescription( + key="ai_pet_delay", + translation_key="ai_animal_delay", + icon="mdi:paw", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=0, + native_max_value=8, + supported=lambda api, ch: ( + api.supported(ch, "ai_delay") and api.supported(ch, "ai_animal") ), value=lambda api, ch: api.ai_delay(ch, "dog_cat"), method=lambda api, ch, value: api.set_ai_delay(ch, int(value), "dog_cat"), @@ -306,6 +334,19 @@ NUMBER_ENTITIES = ( value=lambda api, ch: api.auto_track_stop_time(ch), method=lambda api, ch, value: api.set_auto_tracking(ch, stop_time=int(value)), ), + ReolinkNumberEntityDescription( + key="day_night_switch_threshold", + translation_key="day_night_switch_threshold", + icon="mdi:theme-light-dark", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "dayNightThreshold"), + value=lambda api, ch: api.daynight_threshold(ch), + method=lambda api, ch, value: api.set_daynight_threshold(ch, int(value)), + ), ) @@ -360,5 +401,10 @@ class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - await self.entity_description.method(self._host.api, self._channel, value) + try: + await self.entity_description.method(self._host.api, self._channel, value) + except InvalidParameterError as err: + raise ServiceValidationError(err) from err + except ReolinkError as err: + raise HomeAssistantError(err) from err self.async_write_ha_state() diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index fd42e69268d..566dbc92fbe 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -13,11 +13,13 @@ from reolink_aio.api import ( StatusLedEnum, TrackMethodEnum, ) +from reolink_aio.exceptions import InvalidParameterError, ReolinkError 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.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData @@ -27,20 +29,12 @@ from .entity import ReolinkChannelCoordinatorEntity _LOGGER = logging.getLogger(__name__) -@dataclass -class ReolinkSelectEntityDescriptionMixin: - """Mixin values for Reolink select entities.""" - - method: Callable[[Host, int, str], Any] - get_options: list[str] | Callable[[Host, int], list[str]] - - -@dataclass -class ReolinkSelectEntityDescription( - SelectEntityDescription, ReolinkSelectEntityDescriptionMixin -): +@dataclass(kw_only=True) +class ReolinkSelectEntityDescription(SelectEntityDescription): """A class that describes select entities.""" + get_options: list[str] | Callable[[Host, int], list[str]] + method: Callable[[Host, int, str], Any] supported: Callable[[Host, int], bool] = lambda api, ch: True value: Callable[[Host, int], str] | None = None @@ -169,5 +163,10 @@ class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" - await self.entity_description.method(self._host.api, self._channel, option) + try: + await self.entity_description.method(self._host.api, self._channel, option) + except InvalidParameterError as err: + raise ServiceValidationError(err) from err + except ReolinkError as err: + raise HomeAssistantError(err) from err self.async_write_ha_state() diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index b9e8ddb8e73..9a03f497944 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -24,20 +24,12 @@ from .const import DOMAIN from .entity import ReolinkChannelCoordinatorEntity, ReolinkHostCoordinatorEntity -@dataclass -class ReolinkSensorEntityDescriptionMixin: - """Mixin values for Reolink sensor entities for a camera channel.""" - - value: Callable[[Host, int], int] - - -@dataclass -class ReolinkSensorEntityDescription( - SensorEntityDescription, ReolinkSensorEntityDescriptionMixin -): +@dataclass(kw_only=True) +class ReolinkSensorEntityDescription(SensorEntityDescription): """A class that describes sensor entities for a camera channel.""" supported: Callable[[Host, int], bool] = lambda api, ch: True + value: Callable[[Host, int], int] @dataclass diff --git a/homeassistant/components/reolink/services.yaml b/homeassistant/components/reolink/services.yaml new file mode 100644 index 00000000000..42b9af34eb0 --- /dev/null +++ b/homeassistant/components/reolink/services.yaml @@ -0,0 +1,18 @@ +# Describes the format for available reolink services + +ptz_move: + target: + entity: + integration: reolink + domain: button + supported_features: + - camera.CameraEntityFeature.STREAM + fields: + speed: + required: true + default: 10 + selector: + number: + min: 1 + max: 64 + step: 1 diff --git a/homeassistant/components/reolink/siren.py b/homeassistant/components/reolink/siren.py index c91f633ecab..f063b65e2b4 100644 --- a/homeassistant/components/reolink/siren.py +++ b/homeassistant/components/reolink/siren.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from typing import Any from reolink_aio.api import Host +from reolink_aio.exceptions import InvalidParameterError, ReolinkError from homeassistant.components.siren import ( ATTR_DURATION, @@ -16,6 +17,7 @@ from homeassistant.components.siren import ( ) 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 ReolinkData @@ -84,10 +86,23 @@ class ReolinkSirenEntity(ReolinkChannelCoordinatorEntity, SirenEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the siren.""" if (volume := kwargs.get(ATTR_VOLUME_LEVEL)) is not None: - await self._host.api.set_volume(self._channel, int(volume * 100)) + try: + await self._host.api.set_volume(self._channel, int(volume * 100)) + except InvalidParameterError as err: + raise ServiceValidationError(err) from err + except ReolinkError as err: + raise HomeAssistantError(err) from err duration = kwargs.get(ATTR_DURATION) - await self._host.api.set_siren(self._channel, True, duration) + try: + await self._host.api.set_siren(self._channel, True, duration) + except InvalidParameterError as err: + raise ServiceValidationError(err) from err + except ReolinkError as err: + raise HomeAssistantError(err) from err async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the siren.""" - await self._host.api.set_siren(self._channel, False, None) + try: + await self._host.api.set_siren(self._channel, False, None) + except ReolinkError as err: + raise HomeAssistantError(err) from err diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 15ba4baed45..5a27f0e38cb 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -10,6 +10,9 @@ "use_https": "Enable HTTPS", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Reolink device. For example: '192.168.1.25'." } }, "reauth_confirm": { @@ -61,6 +64,18 @@ "description": "\"{name}\" with model \"{model}\" and hardware version \"{hw_version}\" is running a old firmware version \"{current_firmware}\", while at least firmware version \"{required_firmware}\" is required for proper operation of the Reolink integration. The latest firmware can be downloaded from the [Reolink download center]({download_link})." } }, + "services": { + "ptz_move": { + "name": "PTZ move", + "description": "Move the camera with a specific speed.", + "fields": { + "speed": { + "name": "Speed", + "description": "PTZ move speed." + } + } + } + }, "entity": { "binary_sensor": { "face": { @@ -75,6 +90,9 @@ "pet": { "name": "Pet" }, + "animal": { + "name": "Animal" + }, "visitor": { "name": "Visitor" }, @@ -93,6 +111,9 @@ "pet_lens_0": { "name": "Pet lens 0" }, + "animal_lens_0": { + "name": "Animal lens 0" + }, "visitor_lens_0": { "name": "Visitor lens 0" }, @@ -111,6 +132,9 @@ "pet_lens_1": { "name": "Pet lens 1" }, + "animal_lens_1": { + "name": "Animal lens 1" + }, "visitor_lens_1": { "name": "Visitor lens 1" } @@ -147,6 +171,62 @@ "name": "Guard set current position" } }, + "camera": { + "sub": { + "name": "Fluent" + }, + "main": { + "name": "Clear" + }, + "snapshots_sub": { + "name": "Snapshots fluent" + }, + "snapshots_main": { + "name": "Snapshots clear" + }, + "ext": { + "name": "Balanced" + }, + "sub_lens_0": { + "name": "Fluent lens 0" + }, + "main_lens_0": { + "name": "Clear lens 0" + }, + "snapshots_sub_lens_0": { + "name": "Snapshots fluent lens 0" + }, + "snapshots_main_lens_0": { + "name": "Snapshots clear lens 0" + }, + "ext_lens_0": { + "name": "Balanced lens 0" + }, + "sub_lens_1": { + "name": "Fluent lens 1" + }, + "main_lens_1": { + "name": "Clear lens 1" + }, + "snapshots_sub_lens_1": { + "name": "Snapshots fluent lens 1" + }, + "snapshots_main_lens_1": { + "name": "Snapshots clear lens 1" + }, + "ext_lens_1": { + "name": "Balanced lens 1" + }, + "autotrack_sub": { + "name": "Autotrack fluent" + }, + "autotrack_snapshots_sub": { + "name": "Autotrack snapshots fluent" + }, + "autotrack_snapshots_main": { + "name": "Autotrack snapshots clear" + } + }, "light": { "floodlight": { "name": "Floodlight" @@ -189,6 +269,9 @@ "ai_pet_sensititvity": { "name": "AI pet sensitivity" }, + "ai_animal_sensititvity": { + "name": "AI animal sensitivity" + }, "ai_face_delay": { "name": "AI face delay" }, @@ -201,6 +284,9 @@ "ai_pet_delay": { "name": "AI pet delay" }, + "ai_animal_delay": { + "name": "AI animal delay" + }, "auto_quick_reply_time": { "name": "Auto quick reply time" }, @@ -215,6 +301,9 @@ }, "auto_track_stop_time": { "name": "Auto track stop time" + }, + "day_night_switch_threshold": { + "name": "Day night switch threshold" } }, "select": { @@ -234,7 +323,7 @@ "state": { "auto": "Auto", "color": "Color", - "blackwhite": "Black&White" + "blackwhite": "Black & white" } }, "ptz_preset": { @@ -309,6 +398,9 @@ }, "doorbell_button_sound": { "name": "Doorbell button sound" + }, + "hdr": { + "name": "HDR" } } } diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 4bc817f9c52..eb77b16478f 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -6,11 +6,13 @@ from dataclasses import dataclass from typing import Any from reolink_aio.api import Host +from reolink_aio.exceptions import ReolinkError 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.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData @@ -18,38 +20,22 @@ from .const import DOMAIN from .entity import ReolinkChannelCoordinatorEntity, ReolinkHostCoordinatorEntity -@dataclass -class ReolinkSwitchEntityDescriptionMixin: - """Mixin values for Reolink switch entities.""" - - value: Callable[[Host, int], bool] - method: Callable[[Host, int, bool], Any] - - -@dataclass -class ReolinkSwitchEntityDescription( - SwitchEntityDescription, ReolinkSwitchEntityDescriptionMixin -): +@dataclass(kw_only=True) +class ReolinkSwitchEntityDescription(SwitchEntityDescription): """A class that describes switch entities.""" + method: Callable[[Host, int, bool], Any] supported: Callable[[Host, int], bool] = lambda api, ch: True + value: Callable[[Host, int], bool] -@dataclass -class ReolinkNVRSwitchEntityDescriptionMixin: - """Mixin values for Reolink NVR switch entities.""" - - value: Callable[[Host], bool] - method: Callable[[Host, bool], Any] - - -@dataclass -class ReolinkNVRSwitchEntityDescription( - SwitchEntityDescription, ReolinkNVRSwitchEntityDescriptionMixin -): +@dataclass(kw_only=True) +class ReolinkNVRSwitchEntityDescription(SwitchEntityDescription): """A class that describes NVR switch entities.""" + method: Callable[[Host, bool], Any] supported: Callable[[Host], bool] = lambda api: True + value: Callable[[Host], bool] SWITCH_ENTITIES = ( @@ -152,6 +138,16 @@ SWITCH_ENTITIES = ( value=lambda api, ch: api.doorbell_button_sound(ch), method=lambda api, ch, value: api.set_volume(ch, doorbell_button_sound=value), ), + ReolinkSwitchEntityDescription( + key="hdr", + translation_key="hdr", + icon="mdi:hdr", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + supported=lambda api, ch: api.supported(ch, "HDR"), + value=lambda api, ch: api.HDR_on(ch) is True, + method=lambda api, ch, value: api.set_HDR(ch, value), + ), ) NVR_SWITCH_ENTITIES = ( @@ -253,12 +249,18 @@ class ReolinkSwitchEntity(ReolinkChannelCoordinatorEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self.entity_description.method(self._host.api, self._channel, True) + try: + await self.entity_description.method(self._host.api, self._channel, True) + except ReolinkError as err: + raise HomeAssistantError(err) from err self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - await self.entity_description.method(self._host.api, self._channel, False) + try: + await self.entity_description.method(self._host.api, self._channel, False) + except ReolinkError as err: + raise HomeAssistantError(err) from err self.async_write_ha_state() @@ -285,10 +287,16 @@ class ReolinkNVRSwitchEntity(ReolinkHostCoordinatorEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self.entity_description.method(self._host.api, True) + try: + await self.entity_description.method(self._host.api, True) + except ReolinkError as err: + raise HomeAssistantError(err) from err self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - await self.entity_description.method(self._host.api, False) + try: + await self.entity_description.method(self._host.api, False) + except ReolinkError as err: + raise HomeAssistantError(err) from err self.async_write_ha_state() diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index 1c10671550d..ffd429e92ad 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -1,6 +1,7 @@ """Update entities for Reolink devices.""" from __future__ import annotations +from datetime import datetime import logging from typing import Any, Literal @@ -13,9 +14,10 @@ from homeassistant.components.update import ( UpdateEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_call_later from . import ReolinkData from .const import DOMAIN @@ -23,6 +25,8 @@ from .entity import ReolinkBaseCoordinatorEntity LOGGER = logging.getLogger(__name__) +POLL_AFTER_INSTALL = 120 + async def async_setup_entry( hass: HomeAssistant, @@ -51,6 +55,7 @@ class ReolinkUpdateEntity( super().__init__(reolink_data, reolink_data.firmware_coordinator) self._attr_unique_id = f"{self._host.unique_id}" + self._cancel_update: CALLBACK_TYPE | None = None @property def installed_version(self) -> str | None: @@ -98,3 +103,18 @@ class ReolinkUpdateEntity( raise HomeAssistantError( f"Error trying to update Reolink firmware: {err}" ) from err + finally: + self.async_write_ha_state() + self._cancel_update = async_call_later( + self.hass, POLL_AFTER_INSTALL, self._async_update_future + ) + + async def _async_update_future(self, now: datetime | None = None) -> None: + """Request update.""" + await self.async_update() + + async def async_will_remove_from_hass(self) -> None: + """Entity removed.""" + await super().async_will_remove_from_hass() + if self._cancel_update is not None: + self._cancel_update() diff --git a/homeassistant/components/repetier/manifest.json b/homeassistant/components/repetier/manifest.json index 5ad3db89ba0..dfddb298284 100644 --- a/homeassistant/components/repetier/manifest.json +++ b/homeassistant/components/repetier/manifest.json @@ -1,7 +1,7 @@ { "domain": "repetier", "name": "Repetier-Server", - "codeowners": ["@MTrab", "@ShadowBr0ther"], + "codeowners": ["@ShadowBr0ther"], "documentation": "https://www.home-assistant.io/integrations/repetier", "iot_class": "local_polling", "loggers": ["pyrepetierng"], diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 179dd04cfaa..54a60d34229 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -566,10 +566,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) list_of_ports = {} for port in ports: - list_of_ports[ - port.device - ] = f"{port}, s/n: {port.serial_number or 'n/a'}" + ( - f" - {port.manufacturer}" if port.manufacturer else "" + list_of_ports[port.device] = ( + f"{port}, s/n: {port.serial_number or 'n/a'}" + + (f" - {port.manufacturer}" if port.manufacturer else "") ) list_of_ports[CONF_MANUAL_PATH] = CONF_MANUAL_PATH diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index 85ddf559cf5..9b99553d3f0 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -19,6 +19,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, + "data_description": { + "host": "The hostname or IP address of your RFXCOM RFXtrx device." + }, "title": "Select connection address" }, "setup_serial": { diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index 56aad1a845b..157a62df05b 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -8,36 +8,33 @@ from functools import partial import logging from typing import Any -from oauthlib.oauth2 import AccessDeniedError -import requests -from ring_doorbell import Auth, Ring +import ring_doorbell from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform, __version__ +from homeassistant.const import APPLICATION_NAME, CONF_TOKEN, __version__ from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from homeassistant.helpers.event import async_track_time_interval from homeassistant.util.async_ import run_callback_threadsafe +from .const import ( + DEVICES_SCAN_INTERVAL, + DOMAIN, + HEALTH_SCAN_INTERVAL, + HISTORY_SCAN_INTERVAL, + NOTIFICATIONS_SCAN_INTERVAL, + PLATFORMS, + RING_API, + RING_DEVICES, + RING_DEVICES_COORDINATOR, + RING_HEALTH_COORDINATOR, + RING_HISTORY_COORDINATOR, + RING_NOTIFICATIONS_COORDINATOR, +) + _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Data provided by Ring.com" - -NOTIFICATION_ID = "ring_notification" -NOTIFICATION_TITLE = "Ring Setup" - -DOMAIN = "ring" -DEFAULT_ENTITY_NAMESPACE = "ring" - -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.LIGHT, - Platform.SENSOR, - Platform.SWITCH, - Platform.CAMERA, - Platform.SIREN, -] - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" @@ -49,48 +46,50 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: partial( hass.config_entries.async_update_entry, entry, - data={**entry.data, "token": token}, + data={**entry.data, CONF_TOKEN: token}, ), ).result() - auth = Auth(f"HomeAssistant/{__version__}", entry.data["token"], token_updater) - ring = Ring(auth) + auth = ring_doorbell.Auth( + f"{APPLICATION_NAME}/{__version__}", entry.data[CONF_TOKEN], token_updater + ) + ring = ring_doorbell.Ring(auth) try: await hass.async_add_executor_job(ring.update_data) - except AccessDeniedError: - _LOGGER.error("Access token is no longer valid. Please set up Ring again") - return False + except ring_doorbell.AuthenticationError as err: + _LOGGER.warning("Ring access token is no longer valid, need to re-authenticate") + raise ConfigEntryAuthFailed(err) from err hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - "api": ring, - "devices": ring.devices(), - "device_data": GlobalDataUpdater( - hass, "device", entry.entry_id, ring, "update_devices", timedelta(minutes=1) + RING_API: ring, + RING_DEVICES: ring.devices(), + RING_DEVICES_COORDINATOR: GlobalDataUpdater( + hass, "device", entry, ring, "update_devices", DEVICES_SCAN_INTERVAL ), - "dings_data": GlobalDataUpdater( + RING_NOTIFICATIONS_COORDINATOR: GlobalDataUpdater( hass, "active dings", - entry.entry_id, + entry, ring, "update_dings", - timedelta(seconds=5), + NOTIFICATIONS_SCAN_INTERVAL, ), - "history_data": DeviceDataUpdater( + RING_HISTORY_COORDINATOR: DeviceDataUpdater( hass, "history", - entry.entry_id, + entry, ring, lambda device: device.history(limit=10), - timedelta(minutes=1), + HISTORY_SCAN_INTERVAL, ), - "health_data": DeviceDataUpdater( + RING_HEALTH_COORDINATOR: DeviceDataUpdater( hass, "health", - entry.entry_id, + entry, ring, lambda device: device.update_health_data(), - timedelta(minutes=1), + HEALTH_SCAN_INTERVAL, ), } @@ -143,15 +142,15 @@ class GlobalDataUpdater: self, hass: HomeAssistant, data_type: str, - config_entry_id: str, - ring: Ring, + config_entry: ConfigEntry, + ring: ring_doorbell.Ring, update_method: str, update_interval: timedelta, ) -> None: """Initialize global data updater.""" self.hass = hass self.data_type = data_type - self.config_entry_id = config_entry_id + self.config_entry = config_entry self.ring = ring self.update_method = update_method self.update_interval = update_interval @@ -187,17 +186,19 @@ class GlobalDataUpdater: await self.hass.async_add_executor_job( getattr(self.ring, self.update_method) ) - except AccessDeniedError: - _LOGGER.error("Ring access token is no longer valid. Set up Ring again") - await self.hass.config_entries.async_unload(self.config_entry_id) + except ring_doorbell.AuthenticationError: + _LOGGER.warning( + "Ring access token is no longer valid, need to re-authenticate" + ) + self.config_entry.async_start_reauth(self.hass) return - except requests.Timeout: + except ring_doorbell.RingTimeout: _LOGGER.warning( "Time out fetching Ring %s data", self.data_type, ) return - except requests.RequestException as err: + except ring_doorbell.RingError as err: _LOGGER.warning( "Error fetching Ring %s data: %s", self.data_type, @@ -216,15 +217,15 @@ class DeviceDataUpdater: self, hass: HomeAssistant, data_type: str, - config_entry_id: str, - ring: Ring, - update_method: Callable[[Ring], Any], + config_entry: ConfigEntry, + ring: ring_doorbell.Ring, + update_method: Callable[[ring_doorbell.Ring], Any], update_interval: timedelta, ) -> None: """Initialize device data updater.""" self.data_type = data_type self.hass = hass - self.config_entry_id = config_entry_id + self.config_entry = config_entry self.ring = ring self.update_method = update_method self.update_interval = update_interval @@ -276,20 +277,22 @@ class DeviceDataUpdater: for device_id, info in self.devices.items(): try: data = info["data"] = self.update_method(info["device"]) - except AccessDeniedError: - _LOGGER.error("Ring access token is no longer valid. Set up Ring again") - self.hass.add_job( - self.hass.config_entries.async_unload(self.config_entry_id) + except ring_doorbell.AuthenticationError: + _LOGGER.warning( + "Ring access token is no longer valid, need to re-authenticate" + ) + self.hass.loop.call_soon_threadsafe( + self.config_entry.async_start_reauth, self.hass ) return - except requests.Timeout: + except ring_doorbell.RingTimeout: _LOGGER.warning( "Time out fetching Ring %s data for device %s", self.data_type, device_id, ) continue - except requests.RequestException as err: + except ring_doorbell.RingError as err: _LOGGER.warning( "Error fetching Ring %s data for device %s: %s", self.data_type, diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index ab7207f0ac4..05d26812f54 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN +from .const import DOMAIN, RING_API, RING_DEVICES, RING_NOTIFICATIONS_COORDINATOR from .entity import RingEntityMixin @@ -53,8 +53,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Ring binary sensors from a config entry.""" - ring = hass.data[DOMAIN][config_entry.entry_id]["api"] - devices = hass.data[DOMAIN][config_entry.entry_id]["devices"] + ring = hass.data[DOMAIN][config_entry.entry_id][RING_API] + devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] entities = [ RingBinarySensor(config_entry.entry_id, ring, device, description) @@ -90,13 +90,15 @@ class RingBinarySensor(RingEntityMixin, BinarySensorEntity): async def async_added_to_hass(self) -> None: """Register callbacks.""" await super().async_added_to_hass() - self.ring_objects["dings_data"].async_add_listener(self._dings_update_callback) + self.ring_objects[RING_NOTIFICATIONS_COORDINATOR].async_add_listener( + self._dings_update_callback + ) self._dings_update_callback() async def async_will_remove_from_hass(self) -> None: """Disconnect callbacks.""" await super().async_will_remove_from_hass() - self.ring_objects["dings_data"].async_remove_listener( + self.ring_objects[RING_NOTIFICATIONS_COORDINATOR].async_remove_listener( self._dings_update_callback ) diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 7f897d17203..196d34600d1 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -16,7 +16,7 @@ from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from . import DOMAIN +from .const import DOMAIN, RING_DEVICES, RING_HISTORY_COORDINATOR from .entity import RingEntityMixin FORCE_REFRESH_INTERVAL = timedelta(minutes=3) @@ -30,7 +30,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up a Ring Door Bell and StickUp Camera.""" - devices = hass.data[DOMAIN][config_entry.entry_id]["devices"] + devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) cams = [] @@ -66,7 +66,7 @@ class RingCam(RingEntityMixin, Camera): """Register callbacks.""" await super().async_added_to_hass() - await self.ring_objects["history_data"].async_track_device( + await self.ring_objects[RING_HISTORY_COORDINATOR].async_track_device( self._device, self._history_update_callback ) @@ -74,7 +74,7 @@ class RingCam(RingEntityMixin, Camera): """Disconnect callbacks.""" await super().async_will_remove_from_hass() - self.ring_objects["history_data"].async_untrack_device( + self.ring_objects[RING_HISTORY_COORDINATOR].async_untrack_device( self._device, self._history_update_callback ) diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py index 9425b2f98a4..5c735a3ee8c 100644 --- a/homeassistant/components/ring/config_flow.py +++ b/homeassistant/components/ring/config_flow.py @@ -1,34 +1,47 @@ """Config flow for Ring integration.""" +from collections.abc import Mapping import logging from typing import Any -from oauthlib.oauth2 import AccessDeniedError, MissingTokenError -from ring_doorbell import Auth +import ring_doorbell import voluptuous as vol from homeassistant import config_entries, core, exceptions -from homeassistant.const import __version__ as ha_version +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + APPLICATION_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, + __version__ as ha_version, +) +from homeassistant.data_entry_flow import FlowResult -from . import DOMAIN +from .const import CONF_2FA, DOMAIN _LOGGER = logging.getLogger(__name__) +STEP_USER_DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) +STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) + async def validate_input(hass: core.HomeAssistant, data): """Validate the user input allows us to connect.""" - auth = Auth(f"HomeAssistant/{ha_version}") + auth = ring_doorbell.Auth(f"{APPLICATION_NAME}/{ha_version}") try: token = await hass.async_add_executor_job( auth.fetch_token, - data["username"], - data["password"], - data.get("2fa"), + data[CONF_USERNAME], + data[CONF_PASSWORD], + data.get(CONF_2FA), ) - except MissingTokenError as err: + except ring_doorbell.Requires2FAError as err: raise Require2FA from err - except AccessDeniedError as err: + except ring_doorbell.AuthenticationError as err: raise InvalidAuth from err return token @@ -40,6 +53,7 @@ class RingConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 user_pass: dict[str, Any] = {} + reauth_entry: ConfigEntry | None = None async def async_step_user(self, user_input=None): """Handle the initial step.""" @@ -47,39 +61,85 @@ class RingConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: try: token = await validate_input(self.hass, user_input) - await self.async_set_unique_id(user_input["username"]) - - return self.async_create_entry( - title=user_input["username"], - data={"username": user_input["username"], "token": token}, - ) except Require2FA: self.user_pass = user_input return await self.async_step_2fa() - except InvalidAuth: errors["base"] = "invalid_auth" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" + else: + await self.async_set_unique_id(user_input[CONF_USERNAME]) + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data={CONF_USERNAME: user_input[CONF_USERNAME], CONF_TOKEN: token}, + ) return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - {vol.Required("username"): str, vol.Required("password"): str} - ), - errors=errors, + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) async def async_step_2fa(self, user_input=None): """Handle 2fa step.""" if user_input: + if self.reauth_entry: + return await self.async_step_reauth_confirm( + {**self.user_pass, **user_input} + ) + return await self.async_step_user({**self.user_pass, **user_input}) return self.async_show_form( step_id="2fa", - data_schema=vol.Schema({vol.Required("2fa"): str}), + data_schema=vol.Schema({vol.Required(CONF_2FA): str}), + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + errors = {} + assert self.reauth_entry is not None + + if user_input: + user_input[CONF_USERNAME] = self.reauth_entry.data[CONF_USERNAME] + try: + token = await validate_input(self.hass, user_input) + except Require2FA: + self.user_pass = user_input + return await self.async_step_2fa() + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + data = { + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_TOKEN: token, + } + self.hass.config_entries.async_update_entry( + self.reauth_entry, data=data + ) + await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + description_placeholders={ + CONF_USERNAME: self.reauth_entry.data[CONF_USERNAME] + }, ) diff --git a/homeassistant/components/ring/const.py b/homeassistant/components/ring/const.py new file mode 100644 index 00000000000..10d517ab4a3 --- /dev/null +++ b/homeassistant/components/ring/const.py @@ -0,0 +1,39 @@ +"""The Ring constants.""" +from __future__ import annotations + +from datetime import timedelta + +from homeassistant.const import Platform + +ATTRIBUTION = "Data provided by Ring.com" + +NOTIFICATION_ID = "ring_notification" +NOTIFICATION_TITLE = "Ring Setup" + +DOMAIN = "ring" +DEFAULT_ENTITY_NAMESPACE = "ring" + +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.LIGHT, + Platform.SENSOR, + Platform.SWITCH, + Platform.CAMERA, + Platform.SIREN, +] + + +DEVICES_SCAN_INTERVAL = timedelta(minutes=1) +NOTIFICATIONS_SCAN_INTERVAL = timedelta(seconds=5) +HISTORY_SCAN_INTERVAL = timedelta(minutes=1) +HEALTH_SCAN_INTERVAL = timedelta(minutes=1) + +RING_API = "api" +RING_DEVICES = "devices" + +RING_DEVICES_COORDINATOR = "device_data" +RING_NOTIFICATIONS_COORDINATOR = "dings_data" +RING_HISTORY_COORDINATOR = "history_data" +RING_HEALTH_COORDINATOR = "health_data" + +CONF_2FA = "2fa" diff --git a/homeassistant/components/ring/diagnostics.py b/homeassistant/components/ring/diagnostics.py new file mode 100644 index 00000000000..105800f8d13 --- /dev/null +++ b/homeassistant/components/ring/diagnostics.py @@ -0,0 +1,43 @@ +"""Diagnostics support for Ring.""" +from __future__ import annotations + +from typing import Any + +import ring_doorbell + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +TO_REDACT = { + "id", + "device_id", + "description", + "first_name", + "last_name", + "email", + "location_id", + "ring_net_id", + "wifi_name", + "latitude", + "longitude", + "address", + "ring_id", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + ring: ring_doorbell.Ring = hass.data[DOMAIN][entry.entry_id]["api"] + devices_raw = [] + for device_type in ring.devices_data: + for device_id in ring.devices_data[device_type]: + devices_raw.append(ring.devices_data[device_type][device_id]) + return async_redact_data( + {"device_data": devices_raw}, + TO_REDACT, + ) diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py index 7160d2ef725..4896ea2db8b 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -3,7 +3,7 @@ from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from . import ATTRIBUTION, DOMAIN +from .const import ATTRIBUTION, DOMAIN, RING_DEVICES_COORDINATOR class RingEntityMixin(Entity): @@ -28,11 +28,15 @@ class RingEntityMixin(Entity): async def async_added_to_hass(self) -> None: """Register callbacks.""" - self.ring_objects["device_data"].async_add_listener(self._update_callback) + self.ring_objects[RING_DEVICES_COORDINATOR].async_add_listener( + self._update_callback + ) async def async_will_remove_from_hass(self) -> None: """Disconnect callbacks.""" - self.ring_objects["device_data"].async_remove_listener(self._update_callback) + self.ring_objects[RING_DEVICES_COORDINATOR].async_remove_listener( + self._update_callback + ) @callback def _update_callback(self) -> None: diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index 93640e2764e..7830b2547a5 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from . import DOMAIN +from .const import DOMAIN, RING_DEVICES from .entity import RingEntityMixin _LOGGER = logging.getLogger(__name__) @@ -34,7 +34,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the lights for the Ring devices.""" - devices = hass.data[DOMAIN][config_entry.entry_id]["devices"] + devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] lights = [] diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index 0b5198f36d3..36514fc8f35 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -1,7 +1,7 @@ { "domain": "ring", "name": "Ring", - "codeowners": [], + "codeowners": ["@sdb9696"], "config_flow": true, "dependencies": ["ffmpeg"], "dhcp": [ @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/ring", "iot_class": "cloud_polling", "loggers": ["ring_doorbell"], - "requirements": ["ring-doorbell==0.7.3"] + "requirements": ["ring-doorbell[listen]==0.8.3"] } diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index af23af07eba..465f6196689 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -14,7 +14,12 @@ from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN +from .const import ( + DOMAIN, + RING_DEVICES, + RING_HEALTH_COORDINATOR, + RING_HISTORY_COORDINATOR, +) from .entity import RingEntityMixin @@ -24,7 +29,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up a sensor for a Ring device.""" - devices = hass.data[DOMAIN][config_entry.entry_id]["devices"] + devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] entities = [ description.cls(config_entry.entry_id, device, description) @@ -75,7 +80,7 @@ class HealthDataRingSensor(RingSensor): """Register callbacks.""" await super().async_added_to_hass() - await self.ring_objects["health_data"].async_track_device( + await self.ring_objects[RING_HEALTH_COORDINATOR].async_track_device( self._device, self._health_update_callback ) @@ -83,7 +88,7 @@ class HealthDataRingSensor(RingSensor): """Disconnect callbacks.""" await super().async_will_remove_from_hass() - self.ring_objects["health_data"].async_untrack_device( + self.ring_objects[RING_HEALTH_COORDINATOR].async_untrack_device( self._device, self._health_update_callback ) @@ -112,7 +117,7 @@ class HistoryRingSensor(RingSensor): """Register callbacks.""" await super().async_added_to_hass() - await self.ring_objects["history_data"].async_track_device( + await self.ring_objects[RING_HISTORY_COORDINATOR].async_track_device( self._device, self._history_update_callback ) @@ -120,7 +125,7 @@ class HistoryRingSensor(RingSensor): """Disconnect callbacks.""" await super().async_will_remove_from_hass() - self.ring_objects["history_data"].async_untrack_device( + self.ring_objects[RING_HISTORY_COORDINATOR].async_untrack_device( self._device, self._history_update_callback ) diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py index 7f1b147471d..7daf7bd69ca 100644 --- a/homeassistant/components/ring/siren.py +++ b/homeassistant/components/ring/siren.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN +from .const import DOMAIN, RING_DEVICES from .entity import RingEntityMixin _LOGGER = logging.getLogger(__name__) @@ -21,7 +21,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the sirens for the Ring devices.""" - devices = hass.data[DOMAIN][config_entry.entry_id]["devices"] + devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] sirens = [] for device in devices["chimes"]: diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index b300e335b19..688e3141beb 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -13,6 +13,13 @@ "data": { "2fa": "Two-factor code" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Ring integration needs to re-authenticate your account {username}", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -20,7 +27,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index 7069acd5f0f..074dfee9bd6 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from . import DOMAIN +from .const import DOMAIN, RING_DEVICES from .entity import RingEntityMixin _LOGGER = logging.getLogger(__name__) @@ -33,7 +33,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the switches for the Ring devices.""" - devices = hass.data[DOMAIN][config_entry.entry_id]["devices"] + devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] switches = [] for device in devices["stickup_cams"]: diff --git a/homeassistant/components/risco/__init__.py b/homeassistant/components/risco/__init__.py index 88f8ba9bdfa..9c62447ee04 100644 --- a/homeassistant/components/risco/__init__.py +++ b/homeassistant/components/risco/__init__.py @@ -35,10 +35,12 @@ from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( + CONF_COMMUNICATION_DELAY, DATA_COORDINATOR, DEFAULT_SCAN_INTERVAL, DOMAIN, EVENTS_COORDINATOR, + MAX_COMMUNICATION_DELAY, TYPE_LOCAL, ) @@ -81,15 +83,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_setup_local_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data = entry.data - risco = RiscoLocal(data[CONF_HOST], data[CONF_PORT], data[CONF_PIN]) + comm_delay = initial_delay = data.get(CONF_COMMUNICATION_DELAY, 0) - try: - await risco.connect() - except CannotConnectError as error: - raise ConfigEntryNotReady() from error - except UnauthorizedError: - _LOGGER.exception("Failed to login to Risco cloud") - return False + while True: + risco = RiscoLocal( + data[CONF_HOST], + data[CONF_PORT], + data[CONF_PIN], + communication_delay=comm_delay, + ) + try: + await risco.connect() + except CannotConnectError as error: + if comm_delay >= MAX_COMMUNICATION_DELAY: + raise ConfigEntryNotReady() from error + comm_delay += 1 + except UnauthorizedError: + _LOGGER.exception("Failed to login to Risco cloud") + return False + else: + break + + if comm_delay > initial_delay: + new_data = data.copy() + new_data[CONF_COMMUNICATION_DELAY] = comm_delay + hass.config_entries.async_update_entry(entry, data=new_data) async def _error(error: Exception) -> None: _LOGGER.error("Error in Risco library: %s", error) diff --git a/homeassistant/components/risco/config_flow.py b/homeassistant/components/risco/config_flow.py index 0f532a376a1..ef96714742d 100644 --- a/homeassistant/components/risco/config_flow.py +++ b/homeassistant/components/risco/config_flow.py @@ -28,10 +28,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( CONF_CODE_ARM_REQUIRED, CONF_CODE_DISARM_REQUIRED, + CONF_COMMUNICATION_DELAY, CONF_HA_STATES_TO_RISCO, CONF_RISCO_STATES_TO_HA, DEFAULT_OPTIONS, DOMAIN, + MAX_COMMUNICATION_DELAY, RISCO_STATES, TYPE_LOCAL, ) @@ -78,16 +80,31 @@ async def validate_cloud_input(hass: core.HomeAssistant, data) -> dict[str, str] async def validate_local_input( hass: core.HomeAssistant, data: Mapping[str, str] -) -> dict[str, str]: +) -> dict[str, Any]: """Validate the user input allows us to connect to a local panel. Data has the keys from LOCAL_SCHEMA with values provided by the user. """ - risco = RiscoLocal(data[CONF_HOST], data[CONF_PORT], data[CONF_PIN]) - await risco.connect() + comm_delay = 0 + while True: + risco = RiscoLocal( + data[CONF_HOST], + data[CONF_PORT], + data[CONF_PIN], + communication_delay=comm_delay, + ) + try: + await risco.connect() + except CannotConnectError as e: + if comm_delay >= MAX_COMMUNICATION_DELAY: + raise e + comm_delay += 1 + else: + break + site_id = risco.id await risco.disconnect() - return {"title": site_id} + return {"title": site_id, "comm_delay": comm_delay} class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -170,7 +187,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return self.async_create_entry( - title=info["title"], data={**user_input, **{CONF_TYPE: TYPE_LOCAL}} + title=info["title"], + data={ + **user_input, + **{CONF_TYPE: TYPE_LOCAL}, + **{CONF_COMMUNICATION_DELAY: info["comm_delay"]}, + }, ) return self.async_show_form( diff --git a/homeassistant/components/risco/const.py b/homeassistant/components/risco/const.py index 9f0e71701c6..800003d2384 100644 --- a/homeassistant/components/risco/const.py +++ b/homeassistant/components/risco/const.py @@ -17,10 +17,13 @@ DEFAULT_SCAN_INTERVAL = 30 TYPE_LOCAL = "local" +MAX_COMMUNICATION_DELAY = 3 + CONF_CODE_ARM_REQUIRED = "code_arm_required" CONF_CODE_DISARM_REQUIRED = "code_disarm_required" CONF_RISCO_STATES_TO_HA = "risco_states_to_ha" CONF_HA_STATES_TO_RISCO = "ha_states_to_risco" +CONF_COMMUNICATION_DELAY = "communication_delay" RISCO_GROUPS = ["A", "B", "C", "D"] RISCO_ARM = "arm" diff --git a/homeassistant/components/risco/manifest.json b/homeassistant/components/risco/manifest.json index 5b208d1fc18..ca28af3d8e5 100644 --- a/homeassistant/components/risco/manifest.json +++ b/homeassistant/components/risco/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["pyrisco"], "quality_scale": "platinum", - "requirements": ["pyrisco==0.5.7"] + "requirements": ["pyrisco==0.5.8"] } diff --git a/homeassistant/components/rituals_perfume_genie/binary_sensor.py b/homeassistant/components/rituals_perfume_genie/binary_sensor.py index 73499fb5ccc..ab13898394c 100644 --- a/homeassistant/components/rituals_perfume_genie/binary_sensor.py +++ b/homeassistant/components/rituals_perfume_genie/binary_sensor.py @@ -21,21 +21,14 @@ from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -@dataclass -class RitualsentityDescriptionMixin: - """Mixin values for Rituals entities.""" +@dataclass(kw_only=True) +class RitualsBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class describing Rituals binary sensor entities.""" is_on_fn: Callable[[Diffuser], bool] has_fn: Callable[[Diffuser], bool] -@dataclass -class RitualsBinarySensorEntityDescription( - BinarySensorEntityDescription, RitualsentityDescriptionMixin -): - """Class describing Rituals binary sensor entities.""" - - ENTITY_DESCRIPTIONS = ( RitualsBinarySensorEntityDescription( key="charging", diff --git a/homeassistant/components/rituals_perfume_genie/number.py b/homeassistant/components/rituals_perfume_genie/number.py index 3e6af33315f..35b5a3bd008 100644 --- a/homeassistant/components/rituals_perfume_genie/number.py +++ b/homeassistant/components/rituals_perfume_genie/number.py @@ -17,21 +17,14 @@ from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -@dataclass -class RitualsNumberEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(kw_only=True) +class RitualsNumberEntityDescription(NumberEntityDescription): + """Class describing Rituals number entities.""" value_fn: Callable[[Diffuser], int] set_value_fn: Callable[[Diffuser, int], Awaitable[Any]] -@dataclass -class RitualsNumberEntityDescription( - NumberEntityDescription, RitualsNumberEntityDescriptionMixin -): - """Class describing Rituals number entities.""" - - ENTITY_DESCRIPTIONS = ( RitualsNumberEntityDescription( key="perfume_amount", diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py index 42e18624d13..2126ecb147f 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -17,21 +17,14 @@ from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -@dataclass -class RitualsEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(kw_only=True) +class RitualsSelectEntityDescription(SelectEntityDescription): + """Class describing Rituals select entities.""" current_fn: Callable[[Diffuser], str] select_fn: Callable[[Diffuser, str], Awaitable[None]] -@dataclass -class RitualsSelectEntityDescription( - SelectEntityDescription, RitualsEntityDescriptionMixin -): - """Class describing Rituals select entities.""" - - ENTITY_DESCRIPTIONS = ( RitualsSelectEntityDescription( key="room_size_square_meter", diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index 09189dabfad..5f7ae45d330 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -21,20 +21,12 @@ from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -@dataclass -class RitualsEntityDescriptionMixin: - """Mixin values for Rituals entities.""" - - value_fn: Callable[[Diffuser], int | str] - - -@dataclass -class RitualsSensorEntityDescription( - SensorEntityDescription, RitualsEntityDescriptionMixin -): +@dataclass(kw_only=True) +class RitualsSensorEntityDescription(SensorEntityDescription): """Class describing Rituals sensor entities.""" has_fn: Callable[[Diffuser], bool] = lambda _: True + value_fn: Callable[[Diffuser], int | str] ENTITY_DESCRIPTIONS = ( diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index b310b2bb2ba..ff49b352c18 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -2,17 +2,20 @@ from __future__ import annotations import asyncio +from collections.abc import Coroutine from datetime import timedelta import logging +from typing import Any +from roborock import RoborockException, RoborockInvalidCredentials from roborock.api import RoborockApiClient from roborock.cloud_api import RoborockMqttClient -from roborock.containers import DeviceData, HomeDataDevice, UserData +from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, UserData from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import CONF_BASE_URL, CONF_USER_DATA, DOMAIN, PLATFORMS from .coordinator import RoborockDataUpdateCoordinator @@ -29,66 +32,113 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: user_data = UserData.from_dict(entry.data[CONF_USER_DATA]) api_client = RoborockApiClient(entry.data[CONF_USERNAME], entry.data[CONF_BASE_URL]) _LOGGER.debug("Getting home data") - home_data = await api_client.get_home_data(user_data) + try: + home_data = await api_client.get_home_data(user_data) + except RoborockInvalidCredentials as err: + raise ConfigEntryAuthFailed("Invalid credentials.") from err + except RoborockException as err: + raise ConfigEntryNotReady("Failed getting Roborock home_data.") from err _LOGGER.debug("Got home data %s", home_data) device_map: dict[str, HomeDataDevice] = { device.duid: device for device in home_data.devices + home_data.received_devices } - product_info = {product.id: product for product in home_data.products} - # Create a mqtt_client, which is needed to get the networking information of the device for local connection and in the future, get the map. - mqtt_clients = { - device.duid: RoborockMqttClient( - user_data, DeviceData(device, product_info[device.product_id].model) - ) - for device in device_map.values() + product_info: dict[str, HomeDataProduct] = { + product.id: product for product in home_data.products } - network_results = await asyncio.gather( - *(mqtt_client.get_networking() for mqtt_client in mqtt_clients.values()) - ) - network_info = { - device.duid: result - for device, result in zip(device_map.values(), network_results) - if result is not None - } - if not network_info: - raise ConfigEntryNotReady( - "Could not get network information about your devices" - ) - coordinator_map: dict[str, RoborockDataUpdateCoordinator] = {} - for device_id, device in device_map.items(): - coordinator_map[device_id] = RoborockDataUpdateCoordinator( - hass, - device, - network_info[device_id], - product_info[device.product_id], - mqtt_clients[device.duid], - ) - await asyncio.gather( - *(coordinator.verify_api() for coordinator in coordinator_map.values()) - ) - # If one device update fails - we still want to set up other devices - await asyncio.gather( - *( - coordinator.async_config_entry_first_refresh() - for coordinator in coordinator_map.values() - ), + # Get a Coordinator if the device is available or if we have connected to the device before + coordinators = await asyncio.gather( + *build_setup_functions(hass, device_map, user_data, product_info), return_exceptions=True, ) + # Valid coordinators are those where we had networking cached or we could get networking + valid_coordinators: list[RoborockDataUpdateCoordinator] = [ + coord + for coord in coordinators + if isinstance(coord, RoborockDataUpdateCoordinator) + ] + if len(valid_coordinators) == 0: + raise ConfigEntryNotReady("No coordinators were able to successfully setup.") hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - device_id: coordinator - for device_id, coordinator in coordinator_map.items() - if coordinator.last_update_success - } # Only add coordinators that succeeded - - if not hass.data[DOMAIN][entry.entry_id]: - # Don't start if no coordinators succeeded. - raise ConfigEntryNotReady("There are no devices that can currently be reached.") - + coordinator.roborock_device_info.device.duid: coordinator + for coordinator in valid_coordinators + } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True +def build_setup_functions( + hass: HomeAssistant, + device_map: dict[str, HomeDataDevice], + user_data: UserData, + product_info: dict[str, HomeDataProduct], +) -> list[Coroutine[Any, Any, RoborockDataUpdateCoordinator | None]]: + """Create a list of setup functions that can later be called asynchronously.""" + setup_functions = [] + for device in device_map.values(): + setup_functions.append( + setup_device(hass, user_data, device, product_info[device.product_id]) + ) + return setup_functions + + +async def setup_device( + hass: HomeAssistant, + user_data: UserData, + device: HomeDataDevice, + product_info: HomeDataProduct, +) -> RoborockDataUpdateCoordinator | None: + """Set up a device Coordinator.""" + mqtt_client = RoborockMqttClient(user_data, DeviceData(device, product_info.name)) + try: + networking = await mqtt_client.get_networking() + if networking is None: + # If the api does not return an error but does return None for + # get_networking - then we need to go through cache checking. + raise RoborockException("Networking request returned None.") + except RoborockException as err: + _LOGGER.warning( + "Not setting up %s because we could not get the network information of the device. " + "Please confirm it is online and the Roborock servers can communicate with it", + device.name, + ) + _LOGGER.debug(err) + raise err + coordinator = RoborockDataUpdateCoordinator( + hass, device, networking, product_info, mqtt_client + ) + # Verify we can communicate locally - if we can't, switch to cloud api + await coordinator.verify_api() + coordinator.api.is_available = True + try: + await coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady: + if isinstance(coordinator.api, RoborockMqttClient): + _LOGGER.warning( + "Not setting up %s because the we failed to get data for the first time using the online client. " + "Please ensure your Home Assistant instance can communicate with this device. " + "You may need to open firewall instances on your Home Assistant network and on your Vacuum's network", + device.name, + ) + # Most of the time if we fail to connect using the mqtt client, the problem is due to firewall, + # but in case if it isn't, the error can be included in debug logs for the user to grab. + if coordinator.last_exception: + _LOGGER.debug(coordinator.last_exception) + raise coordinator.last_exception + elif coordinator.last_exception: + # If this is reached, we have verified that we can communicate with the Vacuum locally, + # so if there is an error here - it is not a communication issue but some other problem + extra_error = f"Please create an issue with the following error included: {coordinator.last_exception}" + _LOGGER.warning( + "Not setting up %s because the coordinator failed to get data for the first time using the " + "offline client %s", + device.name, + extra_error, + ) + raise coordinator.last_exception + return coordinator + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Handle removal of an entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index a8f6a6fbb4f..203f981e51d 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -26,7 +26,7 @@ from .device import RoborockCoordinatedEntity class RoborockBinarySensorDescriptionMixin: """A class that describes binary sensor entities.""" - value_fn: Callable[[DeviceProp], bool] + value_fn: Callable[[DeviceProp], bool | int | None] @dataclass diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py new file mode 100644 index 00000000000..aba86ccb6b6 --- /dev/null +++ b/homeassistant/components/roborock/button.py @@ -0,0 +1,112 @@ +"""Support for Roborock button.""" +from __future__ import annotations + +from dataclasses import dataclass + +from roborock.roborock_typing import RoborockCommand + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify + +from .const import DOMAIN +from .coordinator import RoborockDataUpdateCoordinator +from .device import RoborockEntity + + +@dataclass +class RoborockButtonDescriptionMixin: + """Define an entity description mixin for button entities.""" + + command: RoborockCommand + param: list | dict | None + + +@dataclass +class RoborockButtonDescription( + ButtonEntityDescription, RoborockButtonDescriptionMixin +): + """Describes a Roborock button entity.""" + + +CONSUMABLE_BUTTON_DESCRIPTIONS = [ + RoborockButtonDescription( + key="reset_sensor_consumable", + icon="mdi:eye-outline", + translation_key="reset_sensor_consumable", + command=RoborockCommand.RESET_CONSUMABLE, + param=["sensor_dirty_time"], + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), + RoborockButtonDescription( + key="reset_air_filter_consumable", + icon="mdi:air-filter", + translation_key="reset_air_filter_consumable", + command=RoborockCommand.RESET_CONSUMABLE, + param=["filter_work_time"], + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), + RoborockButtonDescription( + key="reset_side_brush_consumable", + icon="mdi:brush", + translation_key="reset_side_brush_consumable", + command=RoborockCommand.RESET_CONSUMABLE, + param=["side_brush_work_time"], + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), + RoborockButtonDescription( + key="reset_main_brush_consumable", + icon="mdi:brush", + translation_key="reset_main_brush_consumable", + command=RoborockCommand.RESET_CONSUMABLE, + param=["main_brush_work_time"], + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Roborock button platform.""" + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + async_add_entities( + RoborockButtonEntity( + f"{description.key}_{slugify(device_id)}", + coordinator, + description, + ) + for device_id, coordinator in coordinators.items() + for description in CONSUMABLE_BUTTON_DESCRIPTIONS + ) + + +class RoborockButtonEntity(RoborockEntity, ButtonEntity): + """A class to define Roborock button entities.""" + + entity_description: RoborockButtonDescription + + def __init__( + self, + unique_id: str, + coordinator: RoborockDataUpdateCoordinator, + entity_description: RoborockButtonDescription, + ) -> None: + """Create a button entity.""" + super().__init__(unique_id, coordinator.device_info, coordinator.api) + self.entity_description = entity_description + + async def async_press(self) -> None: + """Press the button.""" + await self.send(self.entity_description.command, self.entity_description.param) diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index fcfad6e8cd3..201631f0825 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Roborock.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -16,6 +17,7 @@ from roborock.exceptions import ( import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_USERNAME from homeassistant.data_entry_flow import FlowResult @@ -28,6 +30,7 @@ class RoborockFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Roborock.""" VERSION = 1 + reauth_entry: ConfigEntry | None = None def __init__(self) -> None: """Initialize the config flow.""" @@ -47,21 +50,8 @@ class RoborockFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._username = username _LOGGER.debug("Requesting code for Roborock account") self._client = RoborockApiClient(username) - try: - await self._client.request_code() - except RoborockAccountDoesNotExist: - errors["base"] = "invalid_email" - except RoborockUrlException: - errors["base"] = "unknown_url" - except RoborockInvalidEmail: - errors["base"] = "invalid_email_format" - except RoborockException as ex: - _LOGGER.exception(ex) - errors["base"] = "unknown_roborock" - except Exception as ex: # pylint: disable=broad-except - _LOGGER.exception(ex) - errors["base"] = "unknown" - else: + errors = await self._request_code() + if not errors: return await self.async_step_code() return self.async_show_form( step_id="user", @@ -69,6 +59,25 @@ class RoborockFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) + async def _request_code(self) -> dict: + assert self._client + errors: dict[str, str] = {} + try: + await self._client.request_code() + except RoborockAccountDoesNotExist: + errors["base"] = "invalid_email" + except RoborockUrlException: + errors["base"] = "unknown_url" + except RoborockInvalidEmail: + errors["base"] = "invalid_email_format" + except RoborockException as ex: + _LOGGER.exception(ex) + errors["base"] = "unknown_roborock" + except Exception as ex: # pylint: disable=broad-except + _LOGGER.exception(ex) + errors["base"] = "unknown" + return errors + async def async_step_code( self, user_input: dict[str, Any] | None = None, @@ -91,6 +100,18 @@ class RoborockFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception(ex) errors["base"] = "unknown" else: + if self.reauth_entry is not None: + self.hass.config_entries.async_update_entry( + self.reauth_entry, + data={ + **self.reauth_entry.data, + CONF_USER_DATA: login_data.as_dict(), + }, + ) + await self.hass.config_entries.async_reload( + self.reauth_entry.entry_id + ) + return self.async_abort(reason="reauth_successful") return self._create_entry(self._client, self._username, login_data) return self.async_show_form( @@ -99,6 +120,27 @@ class RoborockFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self._username = entry_data[CONF_USERNAME] + assert self._username + self._client = RoborockApiClient(self._username) + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + errors: dict[str, str] = {} + if user_input is not None: + errors = await self._request_code() + if not errors: + return await self.async_step_code() + return self.async_show_form(step_id="reauth_confirm", errors=errors) + def _create_entry( self, client: RoborockApiClient, username: str, user_data: UserData ) -> FlowResult: diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 36078e53b3e..d7a3a9229f5 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -1,4 +1,6 @@ """Constants for Roborock.""" +from vacuum_map_parser_base.config.drawable import Drawable + from homeassistant.const import Platform DOMAIN = "roborock" @@ -7,11 +9,23 @@ CONF_BASE_URL = "base_url" CONF_USER_DATA = "user_data" PLATFORMS = [ - Platform.VACUUM, + Platform.BUTTON, + Platform.BINARY_SENSOR, + Platform.IMAGE, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.TIME, - Platform.NUMBER, - Platform.BINARY_SENSOR, + Platform.VACUUM, ] + +IMAGE_DRAWABLES: list[Drawable] = [ + Drawable.PATH, + Drawable.CHARGER, + Drawable.VACUUM_POSITION, +] + +IMAGE_CACHE_INTERVAL = 90 + +MAP_SLEEP = 3 diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 0a9f42887a6..cd08cf871d4 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -10,7 +10,9 @@ from roborock.exceptions import RoborockException from roborock.local_api import RoborockLocalClient from roborock.roborock_typing import DeviceProp +from homeassistant.const import ATTR_CONNECTIONS from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -31,7 +33,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): device: HomeDataDevice, device_networking: NetworkInfo, product_info: HomeDataProduct, - cloud_api: RoborockMqttClient | None = None, + cloud_api: RoborockMqttClient, ) -> None: """Initialize.""" super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) @@ -42,7 +44,9 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): DeviceProp(), ) device_data = DeviceData(device, product_info.model, device_networking.ip) - self.api = RoborockLocalClient(device_data) + self.api: RoborockLocalClient | RoborockMqttClient = RoborockLocalClient( + device_data + ) self.cloud_api = cloud_api self.device_info = DeviceInfo( name=self.roborock_device_info.device.name, @@ -51,21 +55,25 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): model=self.roborock_device_info.product.model, sw_version=self.roborock_device_info.device.fv, ) + self.current_map: int | None = None + + if mac := self.roborock_device_info.network_info.mac: + self.device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, mac)} async def verify_api(self) -> None: """Verify that the api is reachable. If it is not, switch clients.""" - try: - await self.api.ping() - except RoborockException: - if isinstance(self.api, RoborockLocalClient): + if isinstance(self.api, RoborockLocalClient): + try: + await self.api.ping() + except RoborockException: _LOGGER.warning( "Using the cloud API for device %s. This is not recommended as it can lead to rate limiting. We recommend making your vacuum accessible by your Home Assistant instance", self.roborock_device_info.device.duid, ) # We use the cloud api if the local api fails to connect. self.api = self.cloud_api - # Right now this should never be called if the cloud api is the primary api, - # but in the future if it is, a new else should be added. + # Right now this should never be called if the cloud api is the primary api, + # but in the future if it is, a new else should be added. async def release(self) -> None: """Disconnect from API.""" @@ -84,6 +92,18 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): """Update data via library.""" try: await self._update_device_prop() + self._set_current_map() except RoborockException as ex: raise UpdateFailed(ex) from ex return self.roborock_device_info.props + + def _set_current_map(self) -> None: + if ( + self.roborock_device_info.props.status is not None + and self.roborock_device_info.props.status.map_status is not None + ): + # The map status represents the map flag as flag * 4 + 3 - + # so we have to invert that in order to get the map flag that we can use to set the current map. + self.current_map = ( + self.roborock_device_info.props.status.map_status - 3 + ) // 4 diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py index 2b005ecade6..71376dd600e 100644 --- a/homeassistant/components/roborock/device.py +++ b/homeassistant/components/roborock/device.py @@ -3,8 +3,9 @@ from typing import Any from roborock.api import AttributeCache, RoborockClient +from roborock.cloud_api import RoborockMqttClient from roborock.command_cache import CacheableAttribute -from roborock.containers import Status +from roborock.containers import Consumable, Status from roborock.exceptions import RoborockException from roborock.roborock_typing import RoborockCommand @@ -36,7 +37,7 @@ class RoborockEntity(Entity): def get_cache(self, attribute: CacheableAttribute) -> AttributeCache: """Get an item from the api cache.""" - return self._api.cache.get(attribute) + return self._api.cache[attribute] async def send( self, @@ -45,7 +46,7 @@ class RoborockEntity(Entity): ) -> dict: """Send a command to a vacuum cleaner.""" try: - response = await self._api.send_command(command, params) + response: dict = await self._api.send_command(command, params) except RoborockException as err: raise HomeAssistantError( f"Error while calling {command.name if isinstance(command, RoborockCommand) else command} with {params}" @@ -80,18 +81,28 @@ class RoborockCoordinatedEntity( def _device_status(self) -> Status: """Return the status of the device.""" data = self.coordinator.data - if data: - status = data.status - if status: - return status - return Status({}) + return data.status + + @property + def cloud_api(self) -> RoborockMqttClient: + """Return the cloud api.""" + return self.coordinator.cloud_api async def send( self, - command: RoborockCommand, + command: RoborockCommand | str, params: dict[str, Any] | list[Any] | int | None = None, ) -> dict: """Overloads normal send command but refreshes coordinator.""" res = await super().send(command, params) await self.coordinator.async_refresh() return res + + def _update_from_listener(self, value: Status | Consumable): + """Update the status or consumable data from a listener and then write the new entity state.""" + if isinstance(value, Status): + self.coordinator.roborock_device_info.props.status = value + else: + self.coordinator.roborock_device_info.props.consumable = value + self.coordinator.data = self.coordinator.roborock_device_info.props + self.async_write_ha_state() diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py new file mode 100644 index 00000000000..5e61bb1d408 --- /dev/null +++ b/homeassistant/components/roborock/image.py @@ -0,0 +1,164 @@ +"""Support for Roborock image.""" +import asyncio +import io +from itertools import chain + +from roborock import RoborockCommand +from vacuum_map_parser_base.config.color import ColorsPalette +from vacuum_map_parser_base.config.image_config import ImageConfig +from vacuum_map_parser_base.config.size import Sizes +from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser + +from homeassistant.components.image import ImageEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify +import homeassistant.util.dt as dt_util + +from .const import DOMAIN, IMAGE_CACHE_INTERVAL, IMAGE_DRAWABLES, MAP_SLEEP +from .coordinator import RoborockDataUpdateCoordinator +from .device import RoborockCoordinatedEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Roborock image platform.""" + + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + entities = list( + chain.from_iterable( + await asyncio.gather( + *(create_coordinator_maps(coord) for coord in coordinators.values()) + ) + ) + ) + async_add_entities(entities) + + +class RoborockMap(RoborockCoordinatedEntity, ImageEntity): + """A class to let you visualize the map.""" + + _attr_has_entity_name = True + + def __init__( + self, + unique_id: str, + coordinator: RoborockDataUpdateCoordinator, + map_flag: int, + starting_map: bytes, + map_name: str, + ) -> None: + """Initialize a Roborock map.""" + RoborockCoordinatedEntity.__init__(self, unique_id, coordinator) + ImageEntity.__init__(self, coordinator.hass) + self._attr_name = map_name + self.parser = RoborockMapDataParser( + ColorsPalette(), Sizes(), IMAGE_DRAWABLES, ImageConfig(), [] + ) + self._attr_image_last_updated = dt_util.utcnow() + self.map_flag = map_flag + self.cached_map = self._create_image(starting_map) + + @property + def entity_category(self) -> EntityCategory | None: + """Return diagnostic entity category for any non-selected maps.""" + if not self.is_selected: + return EntityCategory.DIAGNOSTIC + return None + + @property + def is_selected(self) -> bool: + """Return if this map is the currently selected map.""" + return self.map_flag == self.coordinator.current_map + + def is_map_valid(self) -> bool: + """Update this map if it is the current active map, and the vacuum is cleaning.""" + return ( + self.is_selected + and self.image_last_updated is not None + and self.coordinator.roborock_device_info.props.status is not None + and bool(self.coordinator.roborock_device_info.props.status.in_cleaning) + ) + + def _handle_coordinator_update(self): + # Bump last updated every third time the coordinator runs, so that async_image + # will be called and we will evaluate on the new coordinator data if we should + # update the cache. + if ( + dt_util.utcnow() - self.image_last_updated + ).total_seconds() > IMAGE_CACHE_INTERVAL and self.is_map_valid(): + self._attr_image_last_updated = dt_util.utcnow() + super()._handle_coordinator_update() + + async def async_image(self) -> bytes | None: + """Update the image if it is not cached.""" + if self.is_map_valid(): + map_data: bytes = await self.cloud_api.get_map_v1() + self.cached_map = self._create_image(map_data) + return self.cached_map + + def _create_image(self, map_bytes: bytes) -> bytes: + """Create an image using the map parser.""" + parsed_map = self.parser.parse(map_bytes) + if parsed_map.image is None: + raise HomeAssistantError("Something went wrong creating the map.") + img_byte_arr = io.BytesIO() + parsed_map.image.data.save(img_byte_arr, format="PNG") + return img_byte_arr.getvalue() + + +async def create_coordinator_maps( + coord: RoborockDataUpdateCoordinator, +) -> list[RoborockMap]: + """Get the starting map information for all maps for this device. The following steps must be done synchronously. + + Only one map can be loaded at a time per device. + """ + entities = [] + maps = await coord.cloud_api.get_multi_maps_list() + if maps is not None and maps.map_info is not None: + cur_map = coord.current_map + # This won't be None at this point as the coordinator will have run first. + assert cur_map is not None + # Sort the maps so that we start with the current map and we can skip the + # load_multi_map call. + maps_info = sorted( + maps.map_info, key=lambda data: data.mapFlag == cur_map, reverse=True + ) + for roborock_map in maps_info: + # Load the map - so we can access it with get_map_v1 + if roborock_map.mapFlag != cur_map: + # Only change the map and sleep if we have multiple maps. + await coord.api.send_command( + RoborockCommand.LOAD_MULTI_MAP, [roborock_map.mapFlag] + ) + # We cannot get the map until the roborock servers fully process the + # map change. + await asyncio.sleep(MAP_SLEEP) + # Get the map data + api_data: bytes = await coord.cloud_api.get_map_v1() + entities.append( + RoborockMap( + f"{slugify(coord.roborock_device_info.device.duid)}_map_{roborock_map.name}", + coord, + roborock_map.mapFlag, + api_data, + roborock_map.name, + ) + ) + if len(maps.map_info) != 1: + # Set the map back to the map the user previously had selected so that it + # does not change the end user's app. + # Only needs to happen when we changed maps above. + await coord.cloud_api.send_command( + RoborockCommand.LOAD_MULTI_MAP, [cur_map] + ) + return entities diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 5be48c1f4bf..beb467d69f9 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -6,5 +6,8 @@ "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], - "requirements": ["python-roborock==0.35.0"] + "requirements": [ + "python-roborock==0.36.2", + "vacuum-map-parser-roborock==0.1.1" + ] } diff --git a/homeassistant/components/roborock/number.py b/homeassistant/components/roborock/number.py index 4eaf1464f89..d91606418d9 100644 --- a/homeassistant/components/roborock/number.py +++ b/homeassistant/components/roborock/number.py @@ -74,7 +74,7 @@ async def async_setup_entry( # We need to check if this function is supported by the device. results = await asyncio.gather( *( - coordinator.api.cache.get(description.cache_key).async_value() + coordinator.api.get_from_cache(description.cache_key) for coordinator, description in possible_entities ), return_exceptions=True, diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 5cf71bb12f4..1a05f3ec9c1 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -3,6 +3,7 @@ from collections.abc import Callable from dataclasses import dataclass from roborock.containers import Status +from roborock.roborock_message import RoborockDataProtocol from roborock.roborock_typing import RoborockCommand from homeassistant.components.select import SelectEntity, SelectEntityDescription @@ -24,9 +25,9 @@ class RoborockSelectDescriptionMixin: # The command that the select entity will send to the api. api_command: RoborockCommand # Gets the current value of the select entity. - value_fn: Callable[[Status], str] + value_fn: Callable[[Status], str | None] # Gets all options of the select entity. - options_lambda: Callable[[Status], list[str]] + options_lambda: Callable[[Status], list[str] | None] # Takes the value from the select entiy and converts it for the api. parameter_lambda: Callable[[str, Status], list[int]] @@ -37,27 +38,32 @@ class RoborockSelectDescription( ): """Class to describe an Roborock select entity.""" + protocol_listener: RoborockDataProtocol | None = None + SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [ RoborockSelectDescription( key="water_box_mode", translation_key="mop_intensity", api_command=RoborockCommand.SET_WATER_BOX_CUSTOM_MODE, - value_fn=lambda data: data.water_box_mode.name, + value_fn=lambda data: data.water_box_mode_name, entity_category=EntityCategory.CONFIG, options_lambda=lambda data: data.water_box_mode.keys() - if data.water_box_mode + if data.water_box_mode is not None else None, - parameter_lambda=lambda key, status: [status.water_box_mode.as_dict().get(key)], + parameter_lambda=lambda key, status: [status.get_mop_intensity_code(key)], + protocol_listener=RoborockDataProtocol.WATER_BOX_MODE, ), RoborockSelectDescription( key="mop_mode", translation_key="mop_mode", api_command=RoborockCommand.SET_MOP_MODE, - value_fn=lambda data: data.mop_mode.name, + value_fn=lambda data: data.mop_mode_name, entity_category=EntityCategory.CONFIG, - options_lambda=lambda data: data.mop_mode.keys() if data.mop_mode else None, - parameter_lambda=lambda key, status: [status.mop_mode.as_dict().get(key)], + options_lambda=lambda data: data.mop_mode.keys() + if data.mop_mode is not None + else None, + parameter_lambda=lambda key, status: [status.get_mop_mode_code(key)], ), ] @@ -74,13 +80,15 @@ async def async_setup_entry( ] async_add_entities( RoborockSelectEntity( - f"{description.key}_{slugify(device_id)}", - coordinator, - description, + f"{description.key}_{slugify(device_id)}", coordinator, description, options ) for device_id, coordinator in coordinators.items() for description in SELECT_DESCRIPTIONS - if description.options_lambda(coordinator.roborock_device_info.props.status) + if ( + options := description.options_lambda( + coordinator.roborock_device_info.props.status + ) + ) is not None ) @@ -95,11 +103,14 @@ class RoborockSelectEntity(RoborockCoordinatedEntity, SelectEntity): unique_id: str, coordinator: RoborockDataUpdateCoordinator, entity_description: RoborockSelectDescription, + options: list[str], ) -> None: """Create a select entity.""" self.entity_description = entity_description super().__init__(unique_id, coordinator) - self._attr_options = self.entity_description.options_lambda(self._device_status) + self._attr_options = options + if (protocol := self.entity_description.protocol_listener) is not None: + self.api.add_listener(protocol, self._update_from_listener, self.api.cache) async def async_select_option(self, option: str) -> None: """Set the option.""" diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 113e02e4abe..775fc0cfb5f 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -11,6 +11,7 @@ from roborock.containers import ( RoborockErrorCode, RoborockStateCode, ) +from roborock.roborock_message import RoborockDataProtocol from roborock.roborock_typing import DeviceProp from homeassistant.components.sensor import ( @@ -48,6 +49,8 @@ class RoborockSensorDescription( ): """A class that describes Roborock sensors.""" + protocol_listener: RoborockDataProtocol | None = None + def _dock_error_value_fn(properties: DeviceProp) -> str | None: if ( @@ -67,6 +70,7 @@ SENSOR_DESCRIPTIONS = [ translation_key="main_brush_time_left", value_fn=lambda data: data.consumable.main_brush_time_left, entity_category=EntityCategory.DIAGNOSTIC, + protocol_listener=RoborockDataProtocol.MAIN_BRUSH_WORK_TIME, ), RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, @@ -76,6 +80,7 @@ SENSOR_DESCRIPTIONS = [ translation_key="side_brush_time_left", value_fn=lambda data: data.consumable.side_brush_time_left, entity_category=EntityCategory.DIAGNOSTIC, + protocol_listener=RoborockDataProtocol.SIDE_BRUSH_WORK_TIME, ), RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, @@ -85,6 +90,7 @@ SENSOR_DESCRIPTIONS = [ translation_key="filter_time_left", value_fn=lambda data: data.consumable.filter_time_left, entity_category=EntityCategory.DIAGNOSTIC, + protocol_listener=RoborockDataProtocol.FILTER_WORK_TIME, ), RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, @@ -117,9 +123,10 @@ SENSOR_DESCRIPTIONS = [ icon="mdi:information-outline", device_class=SensorDeviceClass.ENUM, translation_key="status", - value_fn=lambda data: data.status.state.name, + value_fn=lambda data: data.status.state_name, entity_category=EntityCategory.DIAGNOSTIC, options=RoborockStateCode.keys(), + protocol_listener=RoborockDataProtocol.STATE, ), RoborockSensorDescription( key="cleaning_area", @@ -142,9 +149,10 @@ SENSOR_DESCRIPTIONS = [ icon="mdi:alert-circle", translation_key="vacuum_error", device_class=SensorDeviceClass.ENUM, - value_fn=lambda data: data.status.error_code.name, + value_fn=lambda data: data.status.error_code_name, entity_category=EntityCategory.DIAGNOSTIC, options=RoborockErrorCode.keys(), + protocol_listener=RoborockDataProtocol.ERROR_CODE, ), RoborockSensorDescription( key="battery", @@ -152,12 +160,15 @@ SENSOR_DESCRIPTIONS = [ entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + protocol_listener=RoborockDataProtocol.BATTERY, ), RoborockSensorDescription( key="last_clean_start", translation_key="last_clean_start", icon="mdi:clock-time-twelve", - value_fn=lambda data: data.last_clean_record.begin_datetime, + value_fn=lambda data: data.last_clean_record.begin_datetime + if data.last_clean_record is not None + else None, entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.TIMESTAMP, ), @@ -165,7 +176,9 @@ SENSOR_DESCRIPTIONS = [ key="last_clean_end", translation_key="last_clean_end", icon="mdi:clock-time-twelve", - value_fn=lambda data: data.last_clean_record.end_datetime, + value_fn=lambda data: data.last_clean_record.end_datetime + if data.last_clean_record is not None + else None, entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.TIMESTAMP, ), @@ -234,6 +247,8 @@ class RoborockSensorEntity(RoborockCoordinatedEntity, SensorEntity): """Initialize the entity.""" super().__init__(unique_id, coordinator) self.entity_description = description + if (protocol := self.entity_description.protocol_listener) is not None: + self.api.add_listener(protocol, self._update_from_listener, self.api.cache) @property def native_value(self) -> StateType | datetime.datetime: diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 06cffcc2291..67660816de7 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -12,6 +12,10 @@ "data": { "code": "Verification code" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Roborock integration needs to re-authenticate your account" } }, "error": { @@ -23,7 +27,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { @@ -44,6 +49,20 @@ "name": "Water shortage" } }, + "button": { + "reset_sensor_consumable": { + "name": "Reset sensor consumable" + }, + "reset_air_filter_consumable": { + "name": "Reset air filter consumable" + }, + "reset_side_brush_consumable": { + "name": "Reset side brush consumable" + }, + "reset_main_brush_consumable": { + "name": "Reset main brush consumable" + } + }, "number": { "volume": { "name": "Volume" diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index de820ede8fa..3dd7307da72 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -125,7 +125,7 @@ async def async_setup_entry( # We need to check if this function is supported by the device. results = await asyncio.gather( *( - coordinator.api.cache.get(description.cache_key).async_value() + coordinator.api.get_from_cache(description.cache_key) for coordinator, description in possible_entities ), return_exceptions=True, diff --git a/homeassistant/components/roborock/time.py b/homeassistant/components/roborock/time.py index 5dc98e09352..d02d63597ac 100644 --- a/homeassistant/components/roborock/time.py +++ b/homeassistant/components/roborock/time.py @@ -139,7 +139,7 @@ async def async_setup_entry( # We need to check if this function is supported by the device. results = await asyncio.gather( *( - coordinator.api.cache.get(description.cache_key).async_value() + coordinator.api.get_from_cache(description.cache_key) for coordinator, description in possible_entities ), return_exceptions=True, diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 804c0826578..c8b43e74efd 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -2,6 +2,7 @@ from typing import Any from roborock.code_mappings import RoborockStateCode +from roborock.roborock_message import RoborockDataProtocol from roborock.roborock_typing import RoborockCommand from homeassistant.components.vacuum import ( @@ -93,11 +94,18 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): """Initialize a vacuum.""" StateVacuumEntity.__init__(self) RoborockCoordinatedEntity.__init__(self, unique_id, coordinator) - self._attr_fan_speed_list = self._device_status.fan_power.keys() + self._attr_fan_speed_list = self._device_status.fan_power_options + self.api.add_listener( + RoborockDataProtocol.FAN_POWER, self._update_from_listener, self.api.cache + ) + self.api.add_listener( + RoborockDataProtocol.STATE, self._update_from_listener, self.api.cache + ) @property def state(self) -> str | 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) @property @@ -108,7 +116,7 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): @property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" - return self._device_status.fan_power.name + return self._device_status.fan_power_name async def async_start(self) -> None: """Start the vacuum.""" @@ -138,7 +146,7 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): """Set vacuum fan speed.""" await self.send( RoborockCommand.SET_CUSTOM_MODE, - [self._device_status.fan_power.as_dict().get(fan_speed)], + [self._device_status.get_fan_speed_code(fan_speed)], ) async def async_start_pause(self) -> None: diff --git a/homeassistant/components/roku/strings.json b/homeassistant/components/roku/strings.json index 818b43930f4..9eef366163e 100644 --- a/homeassistant/components/roku/strings.json +++ b/homeassistant/components/roku/strings.json @@ -6,6 +6,9 @@ "description": "Enter your Roku information.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Roku device to control." } }, "discovery_confirm": { diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py index 13e78ced379..b5dd9fedbd3 100644 --- a/homeassistant/components/roomba/irobot_base.py +++ b/homeassistant/components/roomba/irobot_base.py @@ -13,7 +13,7 @@ from homeassistant.components.vacuum import ( StateVacuumEntity, VacuumEntityFeature, ) -from homeassistant.const import STATE_IDLE, STATE_PAUSED +from homeassistant.const import ATTR_CONNECTIONS, STATE_IDLE, STATE_PAUSED import homeassistant.helpers.device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -69,9 +69,23 @@ class IRobotEntity(Entity): self.vacuum = roomba self._blid = blid self.vacuum_state = roomba_reported_state(roomba) - self._name = self.vacuum_state.get("name") - self._version = self.vacuum_state.get("softwareVer") - self._sku = self.vacuum_state.get("sku") + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.robot_unique_id)}, + serial_number=self.vacuum_state.get("hwPartsRev", {}).get("navSerialNo"), + manufacturer="iRobot", + model=self.vacuum_state.get("sku"), + name=str(self.vacuum_state.get("name")), + sw_version=self.vacuum_state.get("softwareVer"), + hw_version=self.vacuum_state.get("hardwareRev"), + ) + + if mac_address := self.vacuum_state.get("hwPartsRev", {}).get( + "wlan0HwAddr", self.vacuum_state.get("mac") + ): + self._attr_device_info[ATTR_CONNECTIONS] = { + (dr.CONNECTION_NETWORK_MAC, mac_address) + } @property def robot_unique_id(self): @@ -83,24 +97,6 @@ class IRobotEntity(Entity): """Return the uniqueid of the vacuum cleaner.""" return self.robot_unique_id - @property - def device_info(self): - """Return the device info of the vacuum cleaner.""" - connections = None - if mac_address := self.vacuum_state.get("hwPartsRev", {}).get( - "wlan0HwAddr", self.vacuum_state.get("mac") - ): - connections = {(dr.CONNECTION_NETWORK_MAC, mac_address)} - return DeviceInfo( - connections=connections, - identifiers={(DOMAIN, self.robot_unique_id)}, - serial_number=self.vacuum_state.get("hwPartsRev", {}).get("navSerialNo"), - manufacturer="iRobot", - model=self._sku, - name=str(self._name), - sw_version=self._version, - ) - @property def battery_level(self): """Return the battery level of the vacuum cleaner.""" diff --git a/homeassistant/components/roomba/strings.json b/homeassistant/components/roomba/strings.json index f1816d58613..654c1b7fdfc 100644 --- a/homeassistant/components/roomba/strings.json +++ b/homeassistant/components/roomba/strings.json @@ -7,6 +7,9 @@ "description": "Select a Roomba or Braava.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Roomba or Braava." } }, "manual": { @@ -14,6 +17,9 @@ "description": "No Roomba or Braava have been discovered on your network.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Roomba or Braava." } }, "link": { diff --git a/homeassistant/components/roon/__init__.py b/homeassistant/components/roon/__init__.py index 9969b694895..f721f0bac40 100644 --- a/homeassistant/components/roon/__init__.py +++ b/homeassistant/components/roon/__init__.py @@ -7,7 +7,7 @@ from homeassistant.helpers import device_registry as dr from .const import CONF_ROON_NAME, DOMAIN from .server import RoonServer -PLATFORMS = [Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.EVENT, Platform.MEDIA_PLAYER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/roon/const.py b/homeassistant/components/roon/const.py index 74cf6a38160..01e2aab9685 100644 --- a/homeassistant/components/roon/const.py +++ b/homeassistant/components/roon/const.py @@ -13,8 +13,8 @@ DEFAULT_NAME = "Roon Labs Music Player" ROON_APPINFO = { "extension_id": "home_assistant", - "display_name": "Roon Integration for Home Assistant", - "display_version": "1.0.0", + "display_name": "Home Assistant", + "display_version": "1.0.1", "publisher": "home_assistant", "email": "home_assistant@users.noreply.github.com", "website": "https://www.home-assistant.io/", diff --git a/homeassistant/components/roon/event.py b/homeassistant/components/roon/event.py new file mode 100644 index 00000000000..fc1bb339cd7 --- /dev/null +++ b/homeassistant/components/roon/event.py @@ -0,0 +1,109 @@ +"""Roon event entities.""" +import logging +from typing import cast + +from homeassistant.components.event import EventDeviceClass, EventEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Roon Event from Config Entry.""" + roon_server = hass.data[DOMAIN][config_entry.entry_id] + event_entities = set() + + @callback + def async_add_roon_volume_entity(player_data): + """Add or update Roon event Entity.""" + dev_id = player_data["dev_id"] + if dev_id in event_entities: + return + # new player! + event_entity = RoonEventEntity(roon_server, player_data) + event_entities.add(dev_id) + async_add_entities([event_entity]) + + # start listening for players to be added from the server component + config_entry.async_on_unload( + async_dispatcher_connect( + hass, "roon_media_player", async_add_roon_volume_entity + ) + ) + + +class RoonEventEntity(EventEntity): + """Representation of a Roon Event entity.""" + + _attr_device_class = EventDeviceClass.BUTTON + _attr_event_types = ["volume_up", "volume_down"] + _attr_translation_key = "volume" + + def __init__(self, server, player_data): + """Initialize the entity.""" + self._server = server + self._player_data = player_data + player_name = player_data["display_name"] + self._attr_name = f"{player_name} roon volume" + self._attr_unique_id = self._player_data["dev_id"] + + if self._player_data.get("source_controls"): + dev_model = self._player_data["source_controls"][0].get("display_name") + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.unique_id)}, + # Instead of setting the device name to the entity name, roon + # should be updated to set has_entity_name = True, and set the entity + # name to None + name=cast(str | None, self.name), + manufacturer="RoonLabs", + model=dev_model, + via_device=(DOMAIN, self._server.roon_id), + ) + + @callback + def _roonapi_volume_callback( + self, control_key: str, event: str, value: int + ) -> None: + """Callbacks from the roon api with volume request.""" + + if event != "set_volume": + _LOGGER.debug("Received unsupported roon volume event %s", event) + return + + if value > 0: + event = "volume_up" + else: + event = "volume_down" + + self._trigger_event(event) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register volume hooks with the roon api.""" + + self._server.roonapi.register_volume_control( + self.unique_id, + self.name, + self._roonapi_volume_callback, + 0, + "incremental", + 0, + 0, + 0, + False, + ) + + async def async_will_remove_from_hass(self) -> None: + """Unregister volume hooks from the roon api.""" + self._server.roonapi.unregister_volume_control(self.unique_id) diff --git a/homeassistant/components/roon/manifest.json b/homeassistant/components/roon/manifest.json index 4fa527d0769..2598d9e8de1 100644 --- a/homeassistant/components/roon/manifest.json +++ b/homeassistant/components/roon/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/roon", "iot_class": "local_push", "loggers": ["roonapi"], - "requirements": ["roonapi==0.1.4"] + "requirements": ["roonapi==0.1.5"] } diff --git a/homeassistant/components/roon/server.py b/homeassistant/components/roon/server.py index 32d909ff00f..488fe18aae4 100644 --- a/homeassistant/components/roon/server.py +++ b/homeassistant/components/roon/server.py @@ -105,7 +105,7 @@ class RoonServer: self._exit = True def roonapi_state_callback(self, event, changed_zones): - """Callbacks from the roon api websockets.""" + """Callbacks from the roon api websocket with state change.""" self.hass.add_job(self.async_update_changed_players(changed_zones)) async def async_do_loop(self): diff --git a/homeassistant/components/roon/strings.json b/homeassistant/components/roon/strings.json index f67779e9eaa..a95c6908312 100644 --- a/homeassistant/components/roon/strings.json +++ b/homeassistant/components/roon/strings.json @@ -22,6 +22,20 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "entity": { + "event": { + "volume": { + "state_attributes": { + "event_type": { + "state": { + "volume_up": "Volume up", + "volume_down": "Volume down" + } + } + } + } + } + }, "services": { "transfer": { "name": "Transfer", diff --git a/homeassistant/components/ruckus_unleashed/strings.json b/homeassistant/components/ruckus_unleashed/strings.json index 769cde67d7a..65a39e5e218 100644 --- a/homeassistant/components/ruckus_unleashed/strings.json +++ b/homeassistant/components/ruckus_unleashed/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Ruckus access point." } } }, diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py index dbfd7c44730..384a7a21528 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -2,7 +2,13 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MAC, CONF_MODEL, CONF_NAME +from homeassistant.const import ( + ATTR_CONNECTIONS, + ATTR_IDENTIFIERS, + CONF_MAC, + CONF_MODEL, + CONF_NAME, +) from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -28,8 +34,8 @@ class SamsungTVEntity(Entity): model=config_entry.data.get(CONF_MODEL), ) if self.unique_id: - self._attr_device_info["identifiers"] = {(DOMAIN, self.unique_id)} + self._attr_device_info[ATTR_IDENTIFIERS] = {(DOMAIN, self.unique_id)} if self._mac: - self._attr_device_info["connections"] = { + self._attr_device_info[ATTR_CONNECTIONS] = { (dr.CONNECTION_NETWORK_MAC, self._mac) } diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index f1f237fa4fb..c9d08f756d0 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "The hostname or IP address of your TV." } }, "confirm": { diff --git a/homeassistant/components/scene/services.yaml b/homeassistant/components/scene/services.yaml index 543cefd5b9a..a2139529ccf 100644 --- a/homeassistant/components/scene/services.yaml +++ b/homeassistant/components/scene/services.yaml @@ -54,3 +54,9 @@ create: selector: entity: multiple: true + +delete: + target: + entity: + - integration: homeassistant + domain: scene diff --git a/homeassistant/components/scene/strings.json b/homeassistant/components/scene/strings.json index 3bfea1b09e7..af91b2e227e 100644 --- a/homeassistant/components/scene/strings.json +++ b/homeassistant/components/scene/strings.json @@ -46,6 +46,18 @@ "description": "List of entities to be included in the snapshot. By taking a snapshot, you record the current state of those entities. If you do not want to use the current state of all your entities for this scene, you can combine the `snapshot_entities` with `entities`." } } + }, + "delete": { + "name": "Delete", + "description": "Deletes a dynamically created scene." + } + }, + "exceptions": { + "entity_not_scene": { + "message": "{entity_id} is not a valid scene entity_id." + }, + "entity_not_dynamically_created": { + "message": "The scene {entity_id} is not created with service `scene.create`." } } } diff --git a/homeassistant/components/schlage/__init__.py b/homeassistant/components/schlage/__init__.py index feaa95864d5..96ff32d3e85 100644 --- a/homeassistant/components/schlage/__init__.py +++ b/homeassistant/components/schlage/__init__.py @@ -7,8 +7,9 @@ import pyschlage from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed -from .const import DOMAIN, LOGGER +from .const import DOMAIN from .coordinator import SchlageDataUpdateCoordinator PLATFORMS: list[Platform] = [ @@ -26,8 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: auth = await hass.async_add_executor_job(pyschlage.Auth, username, password) except WarrantException as ex: - LOGGER.error("Schlage authentication failed: %s", ex) - return False + raise ConfigEntryAuthFailed from ex coordinator = SchlageDataUpdateCoordinator(hass, username, pyschlage.Schlage(auth)) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/schlage/config_flow.py b/homeassistant/components/schlage/config_flow.py index 7e095466087..84bc3ef8ef6 100644 --- a/homeassistant/components/schlage/config_flow.py +++ b/homeassistant/components/schlage/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Schlage integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any import pyschlage @@ -8,6 +9,7 @@ from pyschlage.exceptions import NotAuthorizedError import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult @@ -16,6 +18,7 @@ from .const import DOMAIN, LOGGER STEP_USER_DATA_SCHEMA = vol.Schema( {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} ) +STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -23,36 +26,88 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + reauth_entry: ConfigEntry | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" - errors: dict[str, str] = {} - if user_input is not None: - username = user_input[CONF_USERNAME] - password = user_input[CONF_PASSWORD] - try: - user_id = await self.hass.async_add_executor_job( - _authenticate, username, password - ) - except NotAuthorizedError: - errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - LOGGER.exception("Unknown error") - errors["base"] = "unknown" - else: - await self.async_set_unique_id(user_id) - return self.async_create_entry(title=username, data=user_input) + if user_input is None: + return self._show_user_form({}) + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + user_id, errors = await self.hass.async_add_executor_job( + _authenticate, username, password + ) + if user_id is None: + return self._show_user_form(errors) + await self.async_set_unique_id(user_id) + return self.async_create_entry(title=username, data=user_input) + + def _show_user_form(self, errors: dict[str, str]) -> FlowResult: + """Show the user form.""" return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() -def _authenticate(username: str, password: str) -> str: + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + assert self.reauth_entry is not None + if user_input is None: + return self._show_reauth_form({}) + + username = self.reauth_entry.data[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + user_id, errors = await self.hass.async_add_executor_job( + _authenticate, username, password + ) + if user_id is None: + return self._show_reauth_form(errors) + + if self.reauth_entry.unique_id != user_id: + return self.async_abort(reason="wrong_account") + + data = { + CONF_USERNAME: username, + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + self.hass.config_entries.async_update_entry(self.reauth_entry, data=data) + await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + def _show_reauth_form(self, errors: dict[str, str]) -> FlowResult: + """Show the reauth form.""" + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + ) + + +def _authenticate(username: str, password: str) -> tuple[str | None, dict[str, str]]: """Authenticate with the Schlage API.""" - auth = pyschlage.Auth(username, password) - auth.authenticate() - # The user_id property will make a blocking call if it's not already - # cached. To avoid blocking the event loop, we read it here. - return auth.user_id + user_id = None + errors: dict[str, str] = {} + try: + auth = pyschlage.Auth(username, password) + auth.authenticate() + except NotAuthorizedError: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unknown error") + errors["base"] = "unknown" + else: + # The user_id property will make a blocking call if it's not already + # cached. To avoid blocking the event loop, we read it here. + user_id = auth.user_id + return user_id, errors diff --git a/homeassistant/components/schlage/coordinator.py b/homeassistant/components/schlage/coordinator.py index 2b1e8460af2..3d736306d91 100644 --- a/homeassistant/components/schlage/coordinator.py +++ b/homeassistant/components/schlage/coordinator.py @@ -5,10 +5,11 @@ import asyncio from dataclasses import dataclass from pyschlage import Lock, Schlage -from pyschlage.exceptions import Error as SchlageError +from pyschlage.exceptions import Error as SchlageError, NotAuthorizedError from pyschlage.log import LockLog from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER, UPDATE_INTERVAL @@ -43,6 +44,8 @@ class SchlageDataUpdateCoordinator(DataUpdateCoordinator[SchlageData]): """Fetch the latest data from the Schlage API.""" try: locks = await self.hass.async_add_executor_job(self.api.locks) + except NotAuthorizedError as ex: + raise ConfigEntryAuthFailed from ex except SchlageError as ex: raise UpdateFailed("Failed to refresh Schlage data") from ex lock_data = await asyncio.gather( @@ -64,6 +67,8 @@ class SchlageDataUpdateCoordinator(DataUpdateCoordinator[SchlageData]): logs = previous_lock_data.logs try: logs = lock.logs() + except NotAuthorizedError as ex: + raise ConfigEntryAuthFailed from ex except SchlageError as ex: LOGGER.debug('Failed to read logs for lock "%s": %s', lock.name, ex) diff --git a/homeassistant/components/schlage/diagnostics.py b/homeassistant/components/schlage/diagnostics.py new file mode 100644 index 00000000000..af1bf311676 --- /dev/null +++ b/homeassistant/components/schlage/diagnostics.py @@ -0,0 +1,23 @@ +"""Diagnostics support for Schlage.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import SchlageDataUpdateCoordinator + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + config_entry: ConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + # NOTE: Schlage diagnostics are already redacted. + return { + "locks": [ld.lock.get_diagnostics() for ld in coordinator.data.locks.values()] + } diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index f474f739904..1eb7cb2ab0f 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2023.10.0"] + "requirements": ["pyschlage==2023.11.0"] } diff --git a/homeassistant/components/schlage/strings.json b/homeassistant/components/schlage/strings.json index 076ed97e298..721d9e80286 100644 --- a/homeassistant/components/schlage/strings.json +++ b/homeassistant/components/schlage/strings.json @@ -6,6 +6,13 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Schlage integration needs to re-authenticate your account", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -13,7 +20,9 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "wrong_account": "The user credentials provided do not match this Schlage account." } }, "entity": { diff --git a/homeassistant/components/script/helpers.py b/homeassistant/components/script/helpers.py index 9f0d4399d3d..4504869e270 100644 --- a/homeassistant/components/script/helpers.py +++ b/homeassistant/components/script/helpers.py @@ -1,5 +1,6 @@ """Helpers for automation integration.""" from homeassistant.components.blueprint import DomainBlueprints +from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.singleton import singleton @@ -15,8 +16,15 @@ def _blueprint_in_use(hass: HomeAssistant, blueprint_path: str) -> bool: return len(scripts_with_blueprint(hass, blueprint_path)) > 0 +async def _reload_blueprint_scripts(hass: HomeAssistant, blueprint_path: str) -> None: + """Reload all script that rely on a specific blueprint.""" + await hass.services.async_call(DOMAIN, SERVICE_RELOAD) + + @singleton(DATA_BLUEPRINTS) @callback def async_get_blueprints(hass: HomeAssistant) -> DomainBlueprints: """Get script blueprints.""" - return DomainBlueprints(hass, DOMAIN, LOGGER, _blueprint_in_use) + return DomainBlueprints( + hass, DOMAIN, LOGGER, _blueprint_in_use, _reload_blueprint_scripts + ) diff --git a/homeassistant/components/sensibo/entity.py b/homeassistant/components/sensibo/entity.py index 4eff1a011a5..9f20c051576 100644 --- a/homeassistant/components/sensibo/entity.py +++ b/homeassistant/components/sensibo/entity.py @@ -86,6 +86,7 @@ class SensiboDeviceBaseEntity(SensiboBaseEntity): sw_version=self.device_data.fw_ver, hw_version=self.device_data.fw_type, suggested_area=self.device_data.name, + serial_number=self.device_data.serial, ) diff --git a/homeassistant/components/sensibo/manifest.json b/homeassistant/components/sensibo/manifest.json index 016b3a1e9d9..5a195a8a4cc 100644 --- a/homeassistant/components/sensibo/manifest.json +++ b/homeassistant/components/sensibo/manifest.json @@ -15,5 +15,5 @@ "iot_class": "cloud_polling", "loggers": ["pysensibo"], "quality_scale": "platinum", - "requirements": ["pysensibo==1.0.35"] + "requirements": ["pysensibo==1.0.36"] } diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 3cf1dc975ec..d08a20636ab 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -497,19 +497,9 @@ def compile_statistics( # noqa: C901 # Make calculations stat: StatisticData = {"start": start} if "max" in wanted_statistics[entity_id]: - stat["max"] = max( - *itertools.islice( - zip(*valid_float_states), # type: ignore[typeddict-item] - 1, - ) - ) + stat["max"] = max(*itertools.islice(zip(*valid_float_states), 1)) if "min" in wanted_statistics[entity_id]: - stat["min"] = min( - *itertools.islice( - zip(*valid_float_states), # type: ignore[typeddict-item] - 1, - ) - ) + stat["min"] = min(*itertools.islice(zip(*valid_float_states), 1)) if "mean" in wanted_statistics[entity_id]: stat["mean"] = _time_weighted_average(valid_float_states, start, end) diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index fa1044414bb..2af110564e7 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sentry", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["sentry-sdk==1.31.0"] + "requirements": ["sentry-sdk==1.37.1"] } diff --git a/homeassistant/components/senz/strings.json b/homeassistant/components/senz/strings.json index 316f7234f9b..cb1f056d72d 100644 --- a/homeassistant/components/senz/strings.json +++ b/homeassistant/components/senz/strings.json @@ -11,7 +11,10 @@ "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", - "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]" + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/sfr_box/diagnostics.py b/homeassistant/components/sfr_box/diagnostics.py index 1fb98053267..e0e84a7ec1a 100644 --- a/homeassistant/components/sfr_box/diagnostics.py +++ b/homeassistant/components/sfr_box/diagnostics.py @@ -27,16 +27,28 @@ async def async_get_config_entry_diagnostics( }, "data": { "dsl": async_redact_data( - dataclasses.asdict(await data.system.box.dsl_get_info()), TO_REDACT + dataclasses.asdict( + await data.system.box.dsl_get_info() # type:ignore [call-overload] + ), + TO_REDACT, ), "ftth": async_redact_data( - dataclasses.asdict(await data.system.box.ftth_get_info()), TO_REDACT + dataclasses.asdict( + await data.system.box.ftth_get_info() # type:ignore [call-overload] + ), + TO_REDACT, ), "system": async_redact_data( - dataclasses.asdict(await data.system.box.system_get_info()), TO_REDACT + dataclasses.asdict( + await data.system.box.system_get_info() # type:ignore [call-overload] + ), + TO_REDACT, ), "wan": async_redact_data( - dataclasses.asdict(await data.system.box.wan_get_info()), TO_REDACT + dataclasses.asdict( + await data.system.box.wan_get_info() # type:ignore [call-overload] + ), + TO_REDACT, ), }, } diff --git a/homeassistant/components/sfr_box/manifest.json b/homeassistant/components/sfr_box/manifest.json index eb3c9cb1b68..bf4d91a50f1 100644 --- a/homeassistant/components/sfr_box/manifest.json +++ b/homeassistant/components/sfr_box/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sfr_box", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["sfrbox-api==0.0.6"] + "requirements": ["sfrbox-api==0.0.8"] } diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index 1c4540b1c74..f56a9765618 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -188,7 +188,7 @@ SYSTEM_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[SystemInfo], ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value_fn=lambda x: x.temperature / 1000, + value_fn=lambda x: None if x.temperature is None else x.temperature / 1000, ), ) WAN_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[WanInfo], ...] = ( diff --git a/homeassistant/components/sfr_box/strings.json b/homeassistant/components/sfr_box/strings.json index 7ea18304164..6f0001e97ce 100644 --- a/homeassistant/components/sfr_box/strings.json +++ b/homeassistant/components/sfr_box/strings.json @@ -26,6 +26,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]" }, + "data_description": { + "host": "The hostname or IP address of your SFR device." + }, "description": "Setting the credentials is optional, but enables additional functionality." } } diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 5efc5c849d7..b29fdcc6d19 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -73,6 +73,7 @@ BLOCK_SLEEPING_PLATFORMS: Final = [ RPC_PLATFORMS: Final = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CLIMATE, Platform.COVER, Platform.EVENT, Platform.LIGHT, diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 35c18511860..6a592c904f6 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -37,9 +37,12 @@ from .const import ( DOMAIN, LOGGER, NOT_CALIBRATED_ISSUE_ID, + RPC_THERMOSTAT_SETTINGS, SHTRV_01_TEMPERATURE_SETTINGS, ) -from .coordinator import ShellyBlockCoordinator, get_entry_data +from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data +from .entity import ShellyRpcEntity +from .utils import async_remove_shelly_entity, get_device_entry_gen, get_rpc_key_ids async def async_setup_entry( @@ -48,6 +51,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up climate device.""" + if get_device_entry_gen(config_entry) == 2: + return async_setup_rpc_entry(hass, config_entry, async_add_entities) + coordinator = get_entry_data(hass)[config_entry.entry_id].block assert coordinator if coordinator.device.initialized: @@ -105,6 +111,33 @@ def async_restore_climate_entities( break +@callback +def async_setup_rpc_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entities for RPC device.""" + coordinator = get_entry_data(hass)[config_entry.entry_id].rpc + assert coordinator + climate_key_ids = get_rpc_key_ids(coordinator.device.status, "thermostat") + + climate_ids = [] + for id_ in climate_key_ids: + climate_ids.append(id_) + + if coordinator.device.shelly.get("relay_in_thermostat", False): + # Wall Display relay is used as the thermostat actuator, + # we need to remove a switch entity + unique_id = f"{coordinator.mac}-switch:{id_}" + async_remove_shelly_entity(hass, "switch", unique_id) + + if not climate_ids: + return + + async_add_entities(RpcClimate(coordinator, id_) for id_ in climate_ids) + + @dataclass class ShellyClimateExtraStoredData(ExtraStoredData): """Object to hold extra stored data.""" @@ -381,3 +414,74 @@ class BlockSleepingClimate( self.coordinator.entry.async_start_reauth(self.hass) else: self.async_write_ha_state() + + +class RpcClimate(ShellyRpcEntity, ClimateEntity): + """Entity that controls a thermostat on RPC based Shelly devices.""" + + _attr_hvac_modes = [HVACMode.OFF] + _attr_icon = "mdi:thermostat" + _attr_max_temp = RPC_THERMOSTAT_SETTINGS["max"] + _attr_min_temp = RPC_THERMOSTAT_SETTINGS["min"] + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_target_temperature_step = RPC_THERMOSTAT_SETTINGS["step"] + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: + """Initialize.""" + super().__init__(coordinator, f"thermostat:{id_}") + self._id = id_ + self._thermostat_type = coordinator.device.config[f"thermostat:{id_}"].get( + "type", "heating" + ) + if self._thermostat_type == "cooling": + self._attr_hvac_modes.append(HVACMode.COOL) + else: + self._attr_hvac_modes.append(HVACMode.HEAT) + + @property + def target_temperature(self) -> float | None: + """Set target temperature.""" + return cast(float, self.status["target_C"]) + + @property + def current_temperature(self) -> float | None: + """Return current temperature.""" + return cast(float, self.status["current_C"]) + + @property + def hvac_mode(self) -> HVACMode: + """HVAC current mode.""" + if not self.status["enable"]: + return HVACMode.OFF + + return HVACMode.COOL if self._thermostat_type == "cooling" else HVACMode.HEAT + + @property + def hvac_action(self) -> HVACAction: + """HVAC current action.""" + if not self.status["output"]: + return HVACAction.IDLE + + return ( + HVACAction.COOLING + if self._thermostat_type == "cooling" + else HVACAction.HEATING + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if (target_temp := kwargs.get(ATTR_TEMPERATURE)) is None: + return + + await self.call_rpc( + "Thermostat.SetConfig", + {"config": {"id": self._id, "target_C": target_temp}}, + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set hvac mode.""" + mode = hvac_mode in (HVACMode.COOL, HVACMode.HEAT) + await self.call_rpc( + "Thermostat.SetConfig", {"config": {"id": self._id, "enable": mode}} + ) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index bad13fde006..6cde265bc25 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -29,6 +29,7 @@ from .const import ( CONF_SLEEP_PERIOD, DOMAIN, LOGGER, + MODEL_WALL_DISPLAY, BLEScannerMode, ) from .coordinator import async_reconnect_soon, get_entry_data @@ -363,8 +364,10 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool: """Return options flow support for this handler.""" - return config_entry.data.get("gen") == 2 and not config_entry.data.get( - CONF_SLEEP_PERIOD + return ( + config_entry.data.get("gen") == 2 + and not config_entry.data.get(CONF_SLEEP_PERIOD) + and config_entry.data.get("model") != MODEL_WALL_DISPLAY ) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 0275b805208..a90aba8db62 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -6,6 +6,22 @@ from logging import Logger, getLogger import re from typing import Final +from aioshelly.const import ( + MODEL_BULB, + MODEL_BULB_RGBW, + MODEL_BUTTON1, + MODEL_BUTTON1_V2, + MODEL_DIMMER, + MODEL_DIMMER_2, + MODEL_DUO, + MODEL_GAS, + MODEL_MOTION, + MODEL_MOTION_2, + MODEL_RGBW2, + MODEL_VALVE, + MODEL_VINTAGE_V2, + MODEL_WALL_DISPLAY, +) from awesomeversion import AwesomeVersion DOMAIN: Final = "shelly" @@ -24,29 +40,29 @@ LIGHT_TRANSITION_MIN_FIRMWARE_DATE: Final = 20210226 MAX_TRANSITION_TIME: Final = 5000 RGBW_MODELS: Final = ( - "SHBLB-1", - "SHRGBW2", + MODEL_BULB, + MODEL_RGBW2, ) MODELS_SUPPORTING_LIGHT_TRANSITION: Final = ( - "SHBDUO-1", - "SHCB-1", - "SHDM-1", - "SHDM-2", - "SHRGBW2", - "SHVIN-1", + MODEL_DUO, + MODEL_BULB_RGBW, + MODEL_DIMMER, + MODEL_DIMMER_2, + MODEL_RGBW2, + MODEL_VINTAGE_V2, ) MODELS_SUPPORTING_LIGHT_EFFECTS: Final = ( - "SHBLB-1", - "SHCB-1", - "SHRGBW2", + MODEL_BULB, + MODEL_BULB_RGBW, + MODEL_RGBW2, ) # Bulbs that support white & color modes DUAL_MODE_LIGHT_MODELS: Final = ( - "SHBLB-1", - "SHCB-1", + MODEL_BULB, + MODEL_BULB_RGBW, ) # Refresh interval for REST sensors @@ -79,7 +95,11 @@ INPUTS_EVENTS_DICT: Final = { } # List of battery devices that maintain a permanent WiFi connection -BATTERY_DEVICES_WITH_PERMANENT_CONNECTION: Final = ["SHMOS-01"] +BATTERY_DEVICES_WITH_PERMANENT_CONNECTION: Final = [ + MODEL_MOTION, + MODEL_MOTION_2, + MODEL_VALVE, +] # Button/Click events for Block & RPC devices EVENT_SHELLY_CLICK: Final = "shelly.click" @@ -124,7 +144,7 @@ INPUTS_EVENTS_SUBTYPES: Final = { "button4": 4, } -SHBTN_MODELS: Final = ["SHBTN-1", "SHBTN-2"] +SHBTN_MODELS: Final = [MODEL_BUTTON1, MODEL_BUTTON1_V2] STANDARD_RGB_EFFECTS: Final = { 0: "Off", @@ -149,6 +169,11 @@ SHTRV_01_TEMPERATURE_SETTINGS: Final = { "step": 0.5, "default": 20.0, } +RPC_THERMOSTAT_SETTINGS: Final = { + "min": 5, + "max": 35, + "step": 0.5, +} # Kelvin value for colorTemp KELVIN_MAX_VALUE: Final = 6500 @@ -160,7 +185,7 @@ UPTIME_DEVIATION: Final = 5 # Time to wait before reloading entry upon device config change ENTRY_RELOAD_COOLDOWN = 60 -SHELLY_GAS_MODELS = ["SHGS-1"] +SHELLY_GAS_MODELS = [MODEL_GAS] BLE_MIN_VERSION = AwesomeVersion("0.12.0-beta2") @@ -186,3 +211,12 @@ OTA_BEGIN = "ota_begin" OTA_ERROR = "ota_error" OTA_PROGRESS = "ota_progress" OTA_SUCCESS = "ota_success" + +GEN1_RELEASE_URL = "https://shelly-api-docs.shelly.cloud/gen1/#changelog" +GEN2_RELEASE_URL = "https://shelly-api-docs.shelly.cloud/gen2/changelog/" +DEVICES_WITHOUT_FIRMWARE_CHANGELOG = ( + MODEL_WALL_DISPLAY, + MODEL_MOTION, + MODEL_MOTION_2, + MODEL_VALVE, +) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index e648a80420a..d1f9d6943bf 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -10,6 +10,7 @@ from typing import Any, Generic, TypeVar, cast import aioshelly from aioshelly.ble import async_ensure_ble_enabled, async_stop_scanner from aioshelly.block_device import BlockDevice, BlockUpdateType +from aioshelly.const import MODEL_VALVE from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from aioshelly.rpc_device import RpcDevice, RpcUpdateType from awesomeversion import AwesomeVersion @@ -219,7 +220,7 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): # Shelly TRV sends information about changing the configuration for no # reason, reloading the config entry is not needed for it. - if self.model == "SHTRV-01": + if self.model == MODEL_VALVE: self._last_cfg_changed = None # For dual mode bulbs ignore change if it is due to mode/effect change @@ -583,7 +584,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): ble_scanner_mode = self.entry.options.get( CONF_BLE_SCANNER_MODE, BLEScannerMode.DISABLED ) - if ble_scanner_mode == BLEScannerMode.DISABLED: + if ble_scanner_mode == BLEScannerMode.DISABLED and self.connected: await async_stop_scanner(self.device) return if AwesomeVersion(self.device.version) < BLE_MIN_VERSION: diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index 1b5cf911e85..af323c82a24 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final from aioshelly.block_device import Block +from aioshelly.const import MODEL_I3 from homeassistant.components.event import ( DOMAIN as EVENT_DOMAIN, @@ -135,7 +136,7 @@ class ShellyBlockEvent(ShellyBlockEntity, EventEntity): self.channel = channel = int(block.channel or 0) + 1 self._attr_unique_id = f"{super().unique_id}-{channel}" - if coordinator.model == "SHIX3-1": + if coordinator.model == MODEL_I3: self._attr_event_types = list(SHIX3_1_INPUTS_EVENTS_TYPES) else: self._attr_event_types = list(BASIC_INPUTS_EVENTS_TYPES) diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 1c3a85f2f5e..829a60b3a9e 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any, cast from aioshelly.block_device import Block +from aioshelly.const import MODEL_BULB from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -254,7 +255,7 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): @property def effect_list(self) -> list[str] | None: """Return the list of supported effects.""" - if self.coordinator.model == "SHBLB-1": + if self.coordinator.model == MODEL_BULB: return list(SHBLB_1_RGB_EFFECTS.values()) return list(STANDARD_RGB_EFFECTS.values()) @@ -267,7 +268,7 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): else: effect_index = self.block.effect - if self.coordinator.model == "SHBLB-1": + if self.coordinator.model == MODEL_BULB: return SHBLB_1_RGB_EFFECTS[effect_index] return STANDARD_RGB_EFFECTS[effect_index] @@ -326,7 +327,7 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): if ATTR_EFFECT in kwargs and ATTR_COLOR_TEMP_KELVIN not in kwargs: # Color effect change - used only in color mode, switch device mode to color set_mode = "color" - if self.coordinator.model == "SHBLB-1": + if self.coordinator.model == MODEL_BULB: effect_dict = SHBLB_1_RGB_EFFECTS else: effect_dict = STANDARD_RGB_EFFECTS diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index c76e2102fa1..b8185712d31 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==6.0.0"], + "requirements": ["aioshelly==6.1.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index b12ad3e4823..9230ae605e0 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -6,6 +6,9 @@ "description": "Before setup, battery-powered devices must be woken up, you can now wake the device up using a button on it.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Shelly device to connect to." } }, "credentials": { diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 395b386993a..5a398182e4d 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -5,13 +5,14 @@ from dataclasses import dataclass from typing import Any, cast from aioshelly.block_device import Block +from aioshelly.const import MODEL_2, MODEL_25, MODEL_GAS from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import GAS_VALVE_OPEN_STATES +from .const import GAS_VALVE_OPEN_STATES, MODEL_WALL_DISPLAY from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data from .entity import ( BlockEntityDescription, @@ -65,7 +66,7 @@ def async_setup_block_entry( assert coordinator # Add Shelly Gas Valve as a switch - if coordinator.model == "SHGS-1": + if coordinator.model == MODEL_GAS: async_setup_block_attribute_entities( hass, async_add_entities, @@ -77,7 +78,7 @@ def async_setup_block_entry( # In roller mode the relay blocks exist but do not contain required info if ( - coordinator.model in ["SHSW-21", "SHSW-25"] + coordinator.model in [MODEL_2, MODEL_25] and coordinator.device.settings["mode"] != "relay" ): return @@ -116,6 +117,15 @@ def async_setup_rpc_entry( if is_rpc_channel_type_light(coordinator.device.config, id_): continue + if coordinator.model == MODEL_WALL_DISPLAY: + if not coordinator.device.shelly.get("relay_in_thermostat", False): + # Wall Display relay is not used as the thermostat actuator, + # we need to remove a climate entity + unique_id = f"{coordinator.mac}-thermostat:{id_}" + async_remove_shelly_entity(hass, "climate", unique_id) + else: + continue + switch_ids.append(id_) unique_id = f"{coordinator.mac}-switch:{id_}" async_remove_shelly_entity(hass, "light", unique_id) diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index d4528f55288..9e52a292108 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -34,7 +34,7 @@ from .entity import ( async_setup_entry_rest, async_setup_entry_rpc, ) -from .utils import get_device_entry_gen +from .utils import get_device_entry_gen, get_release_url LOGGER = logging.getLogger(__name__) @@ -156,10 +156,15 @@ class RestUpdateEntity(ShellyRestAttributeEntity, UpdateEntity): self, block_coordinator: ShellyBlockCoordinator, attribute: str, - description: RestEntityDescription, + description: RestUpdateDescription, ) -> None: """Initialize update entity.""" super().__init__(block_coordinator, attribute, description) + self._attr_release_url = get_release_url( + block_coordinator.device.gen, + block_coordinator.model, + description.beta, + ) self._in_progress_old_version: str | None = None @property @@ -225,11 +230,14 @@ class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity): coordinator: ShellyRpcCoordinator, key: str, attribute: str, - description: RpcEntityDescription, + description: RpcUpdateDescription, ) -> None: """Initialize update entity.""" super().__init__(coordinator, key, attribute, description) self._ota_in_progress: bool = False + self._attr_release_url = get_release_url( + coordinator.device.gen, coordinator.model, description.beta + ) async def async_added_to_hass(self) -> None: """When entity is added to hass.""" @@ -336,3 +344,15 @@ class RpcSleepingUpdateEntity( return None return self.last_state.attributes.get(ATTR_LATEST_VERSION) + + @property + def release_url(self) -> str | None: + """URL to the full release notes.""" + if not self.coordinator.device.initialized: + return None + + return get_release_url( + self.coordinator.device.gen, + self.coordinator.model, + self.entity_description.beta, + ) diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 4d25812361c..6b5c59f28db 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -6,7 +6,14 @@ from typing import Any, cast from aiohttp.web import Request, WebSocketResponse from aioshelly.block_device import COAP, Block, BlockDevice -from aioshelly.const import MODEL_NAMES +from aioshelly.const import ( + MODEL_1L, + MODEL_DIMMER, + MODEL_DIMMER_2, + MODEL_EM3, + MODEL_I3, + MODEL_NAMES, +) from aioshelly.rpc_device import RpcDevice, WsServer from homeassistant.components.http import HomeAssistantView @@ -26,7 +33,10 @@ from .const import ( BASIC_INPUTS_EVENTS_TYPES, CONF_COAP_PORT, DEFAULT_COAP_PORT, + DEVICES_WITHOUT_FIRMWARE_CHANGELOG, DOMAIN, + GEN1_RELEASE_URL, + GEN2_RELEASE_URL, LOGGER, RPC_INPUTS_EVENTS_TYPES, SHBTN_INPUTS_EVENTS_TYPES, @@ -54,7 +64,11 @@ def get_number_of_channels(device: BlockDevice, block: Block) -> int: if block.type == "input": # Shelly Dimmer/1L has two input channels and missing "num_inputs" - if device.settings["device"]["type"] in ["SHDM-1", "SHDM-2", "SHSW-L"]: + if device.settings["device"]["type"] in [ + MODEL_DIMMER, + MODEL_DIMMER_2, + MODEL_1L, + ]: channels = 2 else: channels = device.shelly.get("num_inputs") @@ -103,7 +117,7 @@ def get_block_channel_name(device: BlockDevice, block: Block | None) -> str: if channel_name: return channel_name - if device.settings["device"]["type"] == "SHEM-3": + if device.settings["device"]["type"] == MODEL_EM3: base = ord("A") else: base = ord("1") @@ -133,7 +147,7 @@ def is_block_momentary_input( return False # Shelly 1L has two button settings in the first channel - if settings["device"]["type"] == "SHSW-L": + if settings["device"]["type"] == MODEL_1L: channel = int(block.channel or 0) + 1 button_type = button[0].get("btn" + str(channel) + "_type") else: @@ -177,7 +191,7 @@ def get_block_input_triggers( if device.settings["device"]["type"] in SHBTN_MODELS: trigger_types = SHBTN_INPUTS_EVENTS_TYPES - elif device.settings["device"]["type"] == "SHIX3-1": + elif device.settings["device"]["type"] == MODEL_I3: trigger_types = SHIX3_1_INPUTS_EVENTS_TYPES else: trigger_types = BASIC_INPUTS_EVENTS_TYPES @@ -408,3 +422,11 @@ def mac_address_from_name(name: str) -> str | None: """Convert a name to a mac address.""" mac = name.partition(".")[0].partition("-")[-1] return mac.upper() if len(mac) == 12 else None + + +def get_release_url(gen: int, model: str, beta: bool) -> str | None: + """Return release URL or None.""" + if beta or model in DEVICES_WITHOUT_FIRMWARE_CHANGELOG: + return None + + return GEN1_RELEASE_URL if gen == 1 else GEN2_RELEASE_URL diff --git a/homeassistant/components/sia/alarm_control_panel.py b/homeassistant/components/sia/alarm_control_panel.py index c59150266d9..149a0427ed0 100644 --- a/homeassistant/components/sia/alarm_control_panel.py +++ b/homeassistant/components/sia/alarm_control_panel.py @@ -52,6 +52,8 @@ ENTITY_DESCRIPTION_ALARM = SIAAlarmControlPanelEntityDescription( "CQ": STATE_ALARM_ARMED_AWAY, "CS": STATE_ALARM_ARMED_AWAY, "CF": STATE_ALARM_ARMED_CUSTOM_BYPASS, + "NP": STATE_ALARM_DISARMED, + "NO": STATE_ALARM_DISARMED, "OA": STATE_ALARM_DISARMED, "OB": STATE_ALARM_DISARMED, "OG": STATE_ALARM_DISARMED, @@ -64,8 +66,6 @@ ENTITY_DESCRIPTION_ALARM = SIAAlarmControlPanelEntityDescription( "NE": STATE_ALARM_ARMED_CUSTOM_BYPASS, "NF": STATE_ALARM_ARMED_CUSTOM_BYPASS, "BR": PREVIOUS_STATE, - "NP": PREVIOUS_STATE, - "NO": PREVIOUS_STATE, }, ) diff --git a/homeassistant/components/sma/strings.json b/homeassistant/components/sma/strings.json index f5dc6c16c88..16e5d7408c4 100644 --- a/homeassistant/components/sma/strings.json +++ b/homeassistant/components/sma/strings.json @@ -19,6 +19,9 @@ "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, + "data_description": { + "host": "The hostname or IP address of your SMA device." + }, "description": "Enter your SMA device information.", "title": "Set up SMA Solar" } diff --git a/homeassistant/components/smappee/strings.json b/homeassistant/components/smappee/strings.json index 58abeb57186..2bdbf0dabe8 100644 --- a/homeassistant/components/smappee/strings.json +++ b/homeassistant/components/smappee/strings.json @@ -29,7 +29,11 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "invalid_mdns": "Unsupported device for the Smappee integration.", - "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]" + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" } } } diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 52a02aca745..16558d2c795 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -13,6 +13,10 @@ from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN, + SWING_BOTH, + SWING_HORIZONTAL, + SWING_OFF, + SWING_VERTICAL, ClimateEntity, ClimateEntityFeature, HVACAction, @@ -71,6 +75,20 @@ STATE_TO_AC_MODE = { HVACMode.FAN_ONLY: "fanOnly", } +SWING_TO_FAN_OSCILLATION = { + SWING_BOTH: "all", + SWING_HORIZONTAL: "horizontal", + SWING_VERTICAL: "vertical", + SWING_OFF: "fixed", +} + +FAN_OSCILLATION_TO_SWING = { + value: key for key, value in SWING_TO_FAN_OSCILLATION.items() +} + + +WINDFREE = "windFree" + UNIT_MAP = {"C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT} _LOGGER = logging.getLogger(__name__) @@ -322,18 +340,34 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): """Define a SmartThings Air Conditioner.""" - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE - ) + _hvac_modes: list[HVACMode] - def __init__(self, device): + def __init__(self, device) -> None: """Init the class.""" super().__init__(device) - self._hvac_modes = None + self._hvac_modes = [] + self._attr_preset_mode = None + self._attr_preset_modes = self._determine_preset_modes() + self._attr_swing_modes = self._determine_swing_modes() + self._attr_supported_features = self._determine_supported_features() + + def _determine_supported_features(self) -> ClimateEntityFeature: + features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ) + if self._device.get_capability(Capability.fan_oscillation_mode): + features |= ClimateEntityFeature.SWING_MODE + if (self._attr_preset_modes is not None) and len(self._attr_preset_modes) > 0: + features |= ClimateEntityFeature.PRESET_MODE + return features async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" await self._device.set_fan_mode(fan_mode, set_status=True) + + # setting the fan must reset the preset mode (it deactivates the windFree function) + self._attr_preset_mode = None + # State is set optimistically in the command above, therefore update # the entity state ahead of receiving the confirming push updates self.async_write_ha_state() @@ -407,12 +441,12 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): self._hvac_modes = list(modes) @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the current temperature.""" return self._device.status.temperature @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return device specific state attributes. Include attributes from the Demand Response Load Control (drlc) @@ -432,12 +466,12 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): return state_attributes @property - def fan_mode(self): + def fan_mode(self) -> str: """Return the fan setting.""" return self._device.status.fan_mode @property - def fan_modes(self): + def fan_modes(self) -> list[str]: """Return the list of available fan modes.""" return self._device.status.supported_ac_fan_modes @@ -454,11 +488,62 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): return self._hvac_modes @property - def target_temperature(self): + def target_temperature(self) -> float: """Return the temperature we try to reach.""" return self._device.status.cooling_setpoint @property - def temperature_unit(self): + def temperature_unit(self) -> str: """Return the unit of measurement.""" - return UNIT_MAP.get(self._device.status.attributes[Attribute.temperature].unit) + return UNIT_MAP[self._device.status.attributes[Attribute.temperature].unit] + + def _determine_swing_modes(self) -> list[str]: + """Return the list of available swing modes.""" + supported_modes = self._device.status.attributes[ + Attribute.supported_fan_oscillation_modes + ][0] + supported_swings = [ + FAN_OSCILLATION_TO_SWING.get(m, SWING_OFF) for m in supported_modes + ] + return supported_swings + + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set swing mode.""" + fan_oscillation_mode = SWING_TO_FAN_OSCILLATION[swing_mode] + await self._device.set_fan_oscillation_mode(fan_oscillation_mode) + + # setting the fan must reset the preset mode (it deactivates the windFree function) + self._attr_preset_mode = None + + self.async_schedule_update_ha_state(True) + + @property + def swing_mode(self) -> str: + """Return the swing setting.""" + return FAN_OSCILLATION_TO_SWING.get( + self._device.status.fan_oscillation_mode, SWING_OFF + ) + + def _determine_preset_modes(self) -> list[str] | None: + """Return a list of available preset modes.""" + supported_modes = self._device.status.attributes[ + "supportedAcOptionalMode" + ].value + if WINDFREE in supported_modes: + return [WINDFREE] + return None + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set special modes (currently only windFree is supported).""" + result = await self._device.command( + "main", + "custom.airConditionerOptionalMode", + "setAcOptionalMode", + [preset_mode], + ) + if result: + self._device.status.update_attribute_value("acOptionalMode", preset_mode) + + self._attr_preset_mode = preset_mode + + self.async_write_ha_state() diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 6836a0b9f6b..dcc2f49db0f 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -8,6 +8,7 @@ from email.mime.text import MIMEText import email.utils import logging import os +from pathlib import Path import smtplib import voluptuous as vol @@ -31,6 +32,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.reload import setup_reload_service from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -185,19 +187,23 @@ class MailNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """Build and send a message to a user. - Will send plain text normally, or will build a multipart HTML message - with inline image attachments if images config is defined, or will - build a multipart HTML if html config is defined. + Will send plain text normally, with pictures as attachments if images config is + defined, or will build a multipart HTML if html config is defined. """ subject = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) if data := kwargs.get(ATTR_DATA): if ATTR_HTML in data: msg = _build_html_msg( - message, data[ATTR_HTML], images=data.get(ATTR_IMAGES, []) + self.hass, + message, + data[ATTR_HTML], + images=data.get(ATTR_IMAGES, []), ) else: - msg = _build_multipart_msg(message, images=data.get(ATTR_IMAGES, [])) + msg = _build_multipart_msg( + self.hass, message, images=data.get(ATTR_IMAGES, []) + ) else: msg = _build_text_msg(message) @@ -242,9 +248,34 @@ def _build_text_msg(message): return MIMEText(message) -def _attach_file(atch_name, content_id): - """Create a message attachment.""" +def _attach_file(hass, atch_name, content_id=""): + """Create a message attachment. + + If MIMEImage is successful and content_id is passed (HTML), add images in-line. + Otherwise add them as attachments. + """ try: + file_path = Path(atch_name).parent + if os.path.exists(file_path) and not hass.config.is_allowed_path( + str(file_path) + ): + allow_list = "allowlist_external_dirs" + file_name = os.path.basename(atch_name) + url = "https://www.home-assistant.io/docs/configuration/basic/" + raise ServiceValidationError( + f"Cannot send email with attachment '{file_name} " + f"from directory '{file_path} which is not secure to load data from. " + f"Only folders added to `{allow_list}` are accessible. " + f"See {url} for more information.", + translation_domain=DOMAIN, + translation_key="remote_path_not_allowed", + translation_placeholders={ + "allow_list": allow_list, + "file_path": file_path, + "file_name": file_name, + "url": url, + }, + ) with open(atch_name, "rb") as attachment_file: file_bytes = attachment_file.read() except FileNotFoundError: @@ -258,36 +289,38 @@ def _attach_file(atch_name, content_id): "Attachment %s has an unknown MIME type. Falling back to file", atch_name, ) - attachment = MIMEApplication(file_bytes, Name=atch_name) - attachment["Content-Disposition"] = f'attachment; filename="{atch_name}"' + attachment = MIMEApplication(file_bytes, Name=os.path.basename(atch_name)) + attachment[ + "Content-Disposition" + ] = f'attachment; filename="{os.path.basename(atch_name)}"' + else: + if content_id: + attachment.add_header("Content-ID", f"<{content_id}>") + else: + attachment.add_header( + "Content-Disposition", + f"attachment; filename={os.path.basename(atch_name)}", + ) - attachment.add_header("Content-ID", f"<{content_id}>") return attachment -def _build_multipart_msg(message, images): - """Build Multipart message with in-line images.""" - _LOGGER.debug("Building multipart email with embedded attachment(s)") - msg = MIMEMultipart("related") - msg_alt = MIMEMultipart("alternative") - msg.attach(msg_alt) +def _build_multipart_msg(hass, message, images): + """Build Multipart message with images as attachments.""" + _LOGGER.debug("Building multipart email with image attachme_build_html_msgnt(s)") + msg = MIMEMultipart() body_txt = MIMEText(message) - msg_alt.attach(body_txt) - body_text = [f"

{message}


"] + msg.attach(body_txt) - for atch_num, atch_name in enumerate(images): - cid = f"image{atch_num}" - body_text.append(f'
') - attachment = _attach_file(atch_name, cid) + for atch_name in images: + attachment = _attach_file(hass, atch_name) if attachment: msg.attach(attachment) - body_html = MIMEText("".join(body_text), "html") - msg_alt.attach(body_html) return msg -def _build_html_msg(text, html, images): +def _build_html_msg(hass, text, html, images): """Build Multipart message with in-line images and rich HTML (UTF-8).""" _LOGGER.debug("Building HTML rich email") msg = MIMEMultipart("related") @@ -298,7 +331,7 @@ def _build_html_msg(text, html, images): for atch_name in images: name = os.path.basename(atch_name) - attachment = _attach_file(atch_name, name) + attachment = _attach_file(hass, atch_name, name) if attachment: msg.attach(attachment) return msg diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index b711c2f2009..38dd81ac196 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -4,5 +4,10 @@ "name": "[%key:common::action::reload%]", "description": "Reloads smtp notify services." } + }, + "exceptions": { + "remote_path_not_allowed": { + "message": "Cannot send email with attachment '{file_name} form directory '{file_path} which is not secure to load data from. Only folders added to `{allow_list}` are accessible. See {url} for more information." + } } } diff --git a/homeassistant/components/snapcast/strings.json b/homeassistant/components/snapcast/strings.json index 0d51c7543f1..b5673910595 100644 --- a/homeassistant/components/snapcast/strings.json +++ b/homeassistant/components/snapcast/strings.json @@ -7,6 +7,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, + "data_description": { + "host": "The hostname or IP address of your Snapcast server." + }, "title": "[%key:common::action::connect%]" } }, diff --git a/homeassistant/components/solarlog/strings.json b/homeassistant/components/solarlog/strings.json index 62e923a766d..5f5e2ae7a5f 100644 --- a/homeassistant/components/solarlog/strings.json +++ b/homeassistant/components/solarlog/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "The prefix to be used for your Solar-Log sensors" + }, + "data_description": { + "host": "The hostname or IP address of your Solar-Log device." } } }, diff --git a/homeassistant/components/soma/strings.json b/homeassistant/components/soma/strings.json index 931a33fff56..abf87b3dde2 100644 --- a/homeassistant/components/soma/strings.json +++ b/homeassistant/components/soma/strings.json @@ -16,8 +16,10 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, - "description": "Please enter connection settings of your SOMA Connect.", - "title": "SOMA Connect" + "data_description": { + "host": "The hostname or IP address of your SOMA Connect." + }, + "description": "Please enter connection settings of your SOMA Connect." } } } diff --git a/homeassistant/components/somfy_mylink/strings.json b/homeassistant/components/somfy_mylink/strings.json index 2609e8d893e..90489c0ba34 100644 --- a/homeassistant/components/somfy_mylink/strings.json +++ b/homeassistant/components/somfy_mylink/strings.json @@ -8,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "system_id": "System ID" + }, + "data_description": { + "host": "The hostname or IP address of your Somfy MyLink hub." } } }, diff --git a/homeassistant/components/songpal/manifest.json b/homeassistant/components/songpal/manifest.json index aa1157e8d0b..ce78b8c9f03 100644 --- a/homeassistant/components/songpal/manifest.json +++ b/homeassistant/components/songpal/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "loggers": ["songpal"], "quality_scale": "gold", - "requirements": ["python-songpal==0.15.2"], + "requirements": ["python-songpal==0.16"], "ssdp": [ { "st": "urn:schemas-sony-com:service:ScalarWebAPI:1", diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index 79fab9a2651..7a8ced30eb7 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -16,6 +16,7 @@ from songpal import ( import voluptuous as vol from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, @@ -91,6 +92,7 @@ class SongpalEntity(MediaPlayerEntity): """Class representing a Songpal device.""" _attr_should_poll = False + _attr_device_class = MediaPlayerDeviceClass.RECEIVER _attr_supported_features = ( MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_STEP diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 49caafcc774..27059bba180 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -280,9 +280,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): async def _async_fallback_poll(self) -> None: """Retrieve latest state by polling.""" - await self.hass.data[DATA_SONOS].favorites[ - self.speaker.household_id - ].async_poll() + await ( + self.hass.data[DATA_SONOS].favorites[self.speaker.household_id].async_poll() + ) await self.hass.async_add_executor_job(self._update) def _update(self) -> None: diff --git a/homeassistant/components/soundtouch/strings.json b/homeassistant/components/soundtouch/strings.json index 7af95aab38c..9fc11f7788a 100644 --- a/homeassistant/components/soundtouch/strings.json +++ b/homeassistant/components/soundtouch/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Bose SoundTouch device." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/speedtestdotnet/strings.json b/homeassistant/components/speedtestdotnet/strings.json index 740716db78e..72ebec6c9e0 100644 --- a/homeassistant/components/speedtestdotnet/strings.json +++ b/homeassistant/components/speedtestdotnet/strings.json @@ -21,13 +21,52 @@ "entity": { "sensor": { "ping": { - "name": "Ping" + "name": "Ping", + "state_attributes": { + "server_name": { + "name": "Server name" + }, + "server_country": { + "name": "Server country" + }, + "server_id": { + "name": "Server ID" + } + } }, "download": { - "name": "Download" + "name": "Download", + "state_attributes": { + "server_name": { + "name": "[%key:component::speedtestdotnet::entity::sensor::ping::state_attributes::server_name::name%]" + }, + "server_country": { + "name": "[%key:component::speedtestdotnet::entity::sensor::ping::state_attributes::server_country::name%]" + }, + "server_id": { + "name": "[%key:component::speedtestdotnet::entity::sensor::ping::state_attributes::server_id::name%]" + }, + "bytes_received": { + "name": "Bytes received" + } + } }, "upload": { - "name": "Upload" + "name": "Upload", + "state_attributes": { + "server_name": { + "name": "[%key:component::speedtestdotnet::entity::sensor::ping::state_attributes::server_name::name%]" + }, + "server_country": { + "name": "[%key:component::speedtestdotnet::entity::sensor::ping::state_attributes::server_country::name%]" + }, + "server_id": { + "name": "[%key:component::speedtestdotnet::entity::sensor::ping::state_attributes::server_id::name%]" + }, + "bytes_sent": { + "name": "Bytes sent" + } + } } } } diff --git a/homeassistant/components/spotify/strings.json b/homeassistant/components/spotify/strings.json index ec2721aba8b..02077cbdb43 100644 --- a/homeassistant/components/spotify/strings.json +++ b/homeassistant/components/spotify/strings.json @@ -13,7 +13,11 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "missing_configuration": "The Spotify integration is not configured. Please follow the documentation.", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "reauth_account_mismatch": "The Spotify account authenticated with, does not match the account needed re-authentication." + "reauth_account_mismatch": "The Spotify account authenticated with, does not match the account needed re-authentication.", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "Successfully authenticated with Spotify." diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index e570f6bac0b..c63ba19e0ad 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.22"] + "requirements": ["SQLAlchemy==2.0.23"] } diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index ded663af897..a2df2c313cd 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -117,6 +117,7 @@ class SsdpServiceInfo(BaseServiceInfo): ssdp_ext: str | None = None ssdp_server: str | None = None ssdp_headers: Mapping[str, Any] = field(default_factory=dict) + ssdp_all_locations: set[str] = field(default_factory=set) x_homeassistant_matching_domains: set[str] = field(default_factory=set) @@ -283,6 +284,7 @@ class Scanner: self.hass = hass self._cancel_scan: Callable[[], None] | None = None self._ssdp_listeners: list[SsdpListener] = [] + self._device_tracker = SsdpDeviceTracker() self._callbacks: list[tuple[SsdpCallback, dict[str, str]]] = [] self._description_cache: DescriptionCache | None = None self.integration_matchers = integration_matchers @@ -290,21 +292,7 @@ class Scanner: @property def _ssdp_devices(self) -> list[SsdpDevice]: """Get all seen devices.""" - return [ - ssdp_device - for ssdp_listener in self._ssdp_listeners - for ssdp_device in ssdp_listener.devices.values() - ] - - @property - def _all_headers_from_ssdp_devices( - self, - ) -> dict[tuple[str, str], CaseInsensitiveDict]: - return { - (ssdp_device.udn, dst): headers - for ssdp_device in self._ssdp_devices - for dst, headers in ssdp_device.all_combined_headers.items() - } + return list(self._device_tracker.devices.values()) async def async_register_callback( self, callback: SsdpCallback, match_dict: None | dict[str, str] = None @@ -317,13 +305,16 @@ class Scanner: # Make sure any entries that happened # before the callback was registered are fired - for headers in self._all_headers_from_ssdp_devices.values(): - if _async_headers_match(headers, lower_match_dict): - await _async_process_callbacks( - [callback], - await self._async_headers_to_discovery_info(headers), - SsdpChange.ALIVE, - ) + for ssdp_device in self._ssdp_devices: + for headers in ssdp_device.all_combined_headers.values(): + if _async_headers_match(headers, lower_match_dict): + await _async_process_callbacks( + [callback], + await self._async_headers_to_discovery_info( + ssdp_device, headers + ), + SsdpChange.ALIVE, + ) callback_entry = (callback, lower_match_dict) self._callbacks.append(callback_entry) @@ -386,7 +377,6 @@ class Scanner: async def _async_start_ssdp_listeners(self) -> None: """Start the SSDP Listeners.""" # Devices are shared between all sources. - device_tracker = SsdpDeviceTracker() for source_ip in await async_build_source_set(self.hass): source_ip_str = str(source_ip) if source_ip.version == 6: @@ -405,7 +395,7 @@ class Scanner: callback=self._ssdp_listener_callback, source=source, target=target, - device_tracker=device_tracker, + device_tracker=self._device_tracker, ) ) results = await asyncio.gather( @@ -454,14 +444,16 @@ class Scanner: if info_desc is None: # Fetch info desc in separate task and process from there. self.hass.async_create_task( - self._ssdp_listener_process_with_lookup(ssdp_device, dst, source) + self._ssdp_listener_process_callback_with_lookup( + ssdp_device, dst, source + ) ) return # Info desc known, process directly. - self._ssdp_listener_process(ssdp_device, dst, source, info_desc) + self._ssdp_listener_process_callback(ssdp_device, dst, source, info_desc) - async def _ssdp_listener_process_with_lookup( + async def _ssdp_listener_process_callback_with_lookup( self, ssdp_device: SsdpDevice, dst: DeviceOrServiceType, @@ -469,14 +461,14 @@ class Scanner: ) -> None: """Handle a device/service change.""" location = ssdp_device.location - self._ssdp_listener_process( + self._ssdp_listener_process_callback( ssdp_device, dst, source, await self._async_get_description_dict(location), ) - def _ssdp_listener_process( + def _ssdp_listener_process_callback( self, ssdp_device: SsdpDevice, dst: DeviceOrServiceType, @@ -502,7 +494,7 @@ class Scanner: return discovery_info = discovery_info_from_headers_and_description( - combined_headers, info_desc + ssdp_device, combined_headers, info_desc ) discovery_info.x_homeassistant_matching_domains = matching_domains @@ -557,7 +549,7 @@ class Scanner: return await self._description_cache.async_get_description_dict(location) or {} async def _async_headers_to_discovery_info( - self, headers: CaseInsensitiveDict + self, ssdp_device: SsdpDevice, headers: CaseInsensitiveDict ) -> SsdpServiceInfo: """Combine the headers and description into discovery_info. @@ -567,34 +559,42 @@ class Scanner: location = headers["location"] info_desc = await self._async_get_description_dict(location) - return discovery_info_from_headers_and_description(headers, info_desc) + return discovery_info_from_headers_and_description( + ssdp_device, headers, info_desc + ) async def async_get_discovery_info_by_udn_st( self, udn: str, st: str ) -> SsdpServiceInfo | None: """Return discovery_info for a udn and st.""" - if headers := self._all_headers_from_ssdp_devices.get((udn, st)): - return await self._async_headers_to_discovery_info(headers) + for ssdp_device in self._ssdp_devices: + if ssdp_device.udn == udn: + if headers := ssdp_device.combined_headers(st): + return await self._async_headers_to_discovery_info( + ssdp_device, headers + ) return None async def async_get_discovery_info_by_st(self, st: str) -> list[SsdpServiceInfo]: """Return matching discovery_infos for a st.""" return [ - await self._async_headers_to_discovery_info(headers) - for udn_st, headers in self._all_headers_from_ssdp_devices.items() - if udn_st[1] == st + await self._async_headers_to_discovery_info(ssdp_device, headers) + for ssdp_device in self._ssdp_devices + if (headers := ssdp_device.combined_headers(st)) ] async def async_get_discovery_info_by_udn(self, udn: str) -> list[SsdpServiceInfo]: """Return matching discovery_infos for a udn.""" return [ - await self._async_headers_to_discovery_info(headers) - for udn_st, headers in self._all_headers_from_ssdp_devices.items() - if udn_st[0] == udn + await self._async_headers_to_discovery_info(ssdp_device, headers) + for ssdp_device in self._ssdp_devices + for headers in ssdp_device.all_combined_headers.values() + if ssdp_device.udn == udn ] def discovery_info_from_headers_and_description( + ssdp_device: SsdpDevice, combined_headers: CaseInsensitiveDict, info_desc: Mapping[str, Any], ) -> SsdpServiceInfo: @@ -627,6 +627,7 @@ def discovery_info_from_headers_and_description( ssdp_nt=combined_headers.get_lower("nt"), ssdp_headers=combined_headers, upnp=upnp_info, + ssdp_all_locations=set(ssdp_device.locations), ) diff --git a/homeassistant/components/starlink/strings.json b/homeassistant/components/starlink/strings.json index 0ec85c68956..bc6807e8ba7 100644 --- a/homeassistant/components/starlink/strings.json +++ b/homeassistant/components/starlink/strings.json @@ -26,7 +26,7 @@ "name": "Heating" }, "power_save_idle": { - "name": "[%key:common::state::idle%]" + "name": "Sleep" }, "mast_near_vertical": { "name": "Mast near vertical" diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py index a334171abb8..a3441eb76da 100644 --- a/homeassistant/components/stream/recorder.py +++ b/homeassistant/components/stream/recorder.py @@ -78,7 +78,9 @@ class RecorderOutput(StreamOutput): def write_segment(segment: Segment) -> None: """Write a segment to output.""" + # fmt: off nonlocal output, output_v, output_a, last_stream_id, running_duration, last_sequence + # fmt: on # Because the stream_worker is in a different thread from the record service, # the lookback segments may still have some overlap with the recorder segments if segment.sequence <= last_sequence: diff --git a/homeassistant/components/stt/manifest.json b/homeassistant/components/stt/manifest.json index 53bb7fa1937..265c3363e2b 100644 --- a/homeassistant/components/stt/manifest.json +++ b/homeassistant/components/stt/manifest.json @@ -1,7 +1,7 @@ { "domain": "stt", "name": "Speech-to-text (STT)", - "codeowners": ["@home-assistant/core", "@pvizeli"], + "codeowners": ["@home-assistant/core"], "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/stt", "integration_type": "entity", diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index 15c346fadab..3da91c4aa52 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/suez_water", "iot_class": "cloud_polling", "loggers": ["pysuez", "regex"], - "requirements": ["pysuez==0.1.19"] + "requirements": ["pysuez==0.2.0"] } diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index 43075276be6..d0c1bba211e 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -45,7 +45,7 @@ def setup_platform( password = config[CONF_PASSWORD] counter_id = config[CONF_COUNTER_ID] try: - client = SuezClient(username, password, counter_id) + client = SuezClient(username, password, counter_id, provider=None) if not client.check_credentials(): _LOGGER.warning("Wrong username and/or password") diff --git a/homeassistant/components/surepetcare/config_flow.py b/homeassistant/components/surepetcare/config_flow.py index 7c4509259ad..38bed2e20a9 100644 --- a/homeassistant/components/surepetcare/config_flow.py +++ b/homeassistant/components/surepetcare/config_flow.py @@ -118,6 +118,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="reauth_confirm", + description_placeholders={"username": self._username}, data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), errors=errors, ) diff --git a/homeassistant/components/surepetcare/strings.json b/homeassistant/components/surepetcare/strings.json index 2d297cc829e..c3b7864f36a 100644 --- a/homeassistant/components/surepetcare/strings.json +++ b/homeassistant/components/surepetcare/strings.json @@ -6,6 +6,13 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Re-authenticate by entering password for {username}", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index b76699631cb..a2f08202319 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -153,7 +153,9 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C if not self.available: return None try: - return await self._api.surveillance_station.get_camera_image(self.entity_description.key, self.snapshot_quality) # type: ignore[no-any-return] + return await self._api.surveillance_station.get_camera_image( # type: ignore[no-any-return] + self.entity_description.key, self.snapshot_quality + ) except ( SynologyDSMAPIErrorException, SynologyDSMRequestException, diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py index 16db365f708..3f30fe9b4e9 100644 --- a/homeassistant/components/synology_dsm/media_source.py +++ b/homeassistant/components/synology_dsm/media_source.py @@ -153,8 +153,7 @@ class SynologyPhotosMediaSource(MediaSource): ret = [] for album_item in album_items: mime_type, _ = mimetypes.guess_type(album_item.file_name) - assert isinstance(mime_type, str) - if mime_type.startswith("image/"): + if isinstance(mime_type, str) and mime_type.startswith("image/"): # Force small small thumbnails album_item.thumbnail_size = "sm" ret.append( diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index 90a6f0659ef..9eec64ec5f6 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -9,11 +9,11 @@ from systembridgeconnector.exceptions import ( ConnectionClosedException, ConnectionErrorException, ) -from systembridgeconnector.models.keyboard_key import KeyboardKey -from systembridgeconnector.models.keyboard_text import KeyboardText -from systembridgeconnector.models.open_path import OpenPath -from systembridgeconnector.models.open_url import OpenUrl from systembridgeconnector.version import SUPPORTED_VERSION, Version +from systembridgemodels.keyboard_key import KeyboardKey +from systembridgemodels.keyboard_text import KeyboardText +from systembridgemodels.open_path import OpenPath +from systembridgemodels.open_url import OpenUrl import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -36,8 +36,6 @@ from homeassistant.helpers import ( discovery, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MODULES from .coordinator import SystemBridgeDataUpdateCoordinator @@ -49,6 +47,7 @@ PLATFORMS = [ Platform.MEDIA_PLAYER, Platform.NOTIFY, Platform.SENSOR, + Platform.UPDATE, ] CONF_BRIDGE = "bridge" @@ -329,43 +328,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Reload the config entry when it changed.""" await hass.config_entries.async_reload(entry.entry_id) - - -class SystemBridgeEntity(CoordinatorEntity[SystemBridgeDataUpdateCoordinator]): - """Defines a base System Bridge entity.""" - - _attr_has_entity_name = True - - def __init__( - self, - coordinator: SystemBridgeDataUpdateCoordinator, - api_port: int, - key: str, - ) -> None: - """Initialize the System Bridge entity.""" - super().__init__(coordinator) - - self._hostname = coordinator.data.system.hostname - self._key = f"{self._hostname}_{key}" - self._configuration_url = ( - f"http://{self._hostname}:{api_port}/app/settings.html" - ) - self._mac_address = coordinator.data.system.mac_address - self._uuid = coordinator.data.system.uuid - self._version = coordinator.data.system.version - - @property - def unique_id(self) -> str: - """Return the unique ID for this entity.""" - return self._key - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this System Bridge instance.""" - return DeviceInfo( - configuration_url=self._configuration_url, - connections={(dr.CONNECTION_NETWORK_MAC, self._mac_address)}, - identifiers={(DOMAIN, self._uuid)}, - name=self._hostname, - sw_version=self._version, - ) diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index e3ecc3817a6..511feeaf93c 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -14,9 +14,9 @@ from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SystemBridgeEntity from .const import DOMAIN from .coordinator import SystemBridgeDataUpdateCoordinator +from .entity import SystemBridgeEntity @dataclass diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index a7dea5d6ab2..a001f22c9e8 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -11,9 +11,9 @@ from systembridgeconnector.exceptions import ( ConnectionClosedException, ConnectionErrorException, ) -from systembridgeconnector.models.get_data import GetData -from systembridgeconnector.models.system import System from systembridgeconnector.websocket_client import WebSocketClient +from systembridgemodels.get_data import GetData +from systembridgemodels.system import System import voluptuous as vol from homeassistant import config_entries, exceptions diff --git a/homeassistant/components/system_bridge/const.py b/homeassistant/components/system_bridge/const.py index 77ff953b67d..fc87b609b78 100644 --- a/homeassistant/components/system_bridge/const.py +++ b/homeassistant/components/system_bridge/const.py @@ -10,5 +10,6 @@ MODULES = [ "gpu", "media", "memory", + "processes", "system", ] diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index a4b016d49bd..5a606721b00 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -13,21 +13,22 @@ from systembridgeconnector.exceptions import ( ConnectionClosedException, ConnectionErrorException, ) -from systembridgeconnector.models.battery import Battery -from systembridgeconnector.models.cpu import Cpu -from systembridgeconnector.models.disk import Disk -from systembridgeconnector.models.display import Display -from systembridgeconnector.models.get_data import GetData -from systembridgeconnector.models.gpu import Gpu -from systembridgeconnector.models.media import Media -from systembridgeconnector.models.media_directories import MediaDirectories -from systembridgeconnector.models.media_files import File as MediaFile, MediaFiles -from systembridgeconnector.models.media_get_file import MediaGetFile -from systembridgeconnector.models.media_get_files import MediaGetFiles -from systembridgeconnector.models.memory import Memory -from systembridgeconnector.models.register_data_listener import RegisterDataListener -from systembridgeconnector.models.system import System from systembridgeconnector.websocket_client import WebSocketClient +from systembridgemodels.battery import Battery +from systembridgemodels.cpu import Cpu +from systembridgemodels.disk import Disk +from systembridgemodels.display import Display +from systembridgemodels.get_data import GetData +from systembridgemodels.gpu import Gpu +from systembridgemodels.media import Media +from systembridgemodels.media_directories import MediaDirectories +from systembridgemodels.media_files import File as MediaFile, MediaFiles +from systembridgemodels.media_get_file import MediaGetFile +from systembridgemodels.media_get_files import MediaGetFiles +from systembridgemodels.memory import Memory +from systembridgemodels.processes import Processes +from systembridgemodels.register_data_listener import RegisterDataListener +from systembridgemodels.system import System from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -53,6 +54,7 @@ class SystemBridgeCoordinatorData(BaseModel): gpu: Gpu = None media: Media = None memory: Memory = None + processes: Processes = None system: System = None diff --git a/homeassistant/components/system_bridge/entity.py b/homeassistant/components/system_bridge/entity.py new file mode 100644 index 00000000000..72a6fc93977 --- /dev/null +++ b/homeassistant/components/system_bridge/entity.py @@ -0,0 +1,47 @@ +"""Base entity for the system bridge integration.""" +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import SystemBridgeDataUpdateCoordinator + + +class SystemBridgeEntity(CoordinatorEntity[SystemBridgeDataUpdateCoordinator]): + """Defines a base System Bridge entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: SystemBridgeDataUpdateCoordinator, + api_port: int, + key: str, + ) -> None: + """Initialize the System Bridge entity.""" + super().__init__(coordinator) + + self._hostname = coordinator.data.system.hostname + self._key = f"{self._hostname}_{key}" + self._configuration_url = ( + f"http://{self._hostname}:{api_port}/app/settings.html" + ) + self._mac_address = coordinator.data.system.mac_address + self._uuid = coordinator.data.system.uuid + self._version = coordinator.data.system.version + + @property + def unique_id(self) -> str: + """Return the unique ID for this entity.""" + return self._key + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this System Bridge instance.""" + return DeviceInfo( + configuration_url=self._configuration_url, + connections={(dr.CONNECTION_NETWORK_MAC, self._mac_address)}, + identifiers={(DOMAIN, self._uuid)}, + name=self._hostname, + sw_version=self._version, + ) diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index 64590ecb96f..17c43fa4d24 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -10,6 +10,6 @@ "iot_class": "local_push", "loggers": ["systembridgeconnector"], "quality_scale": "silver", - "requirements": ["systembridgeconnector==3.8.4"], + "requirements": ["systembridgeconnector==3.10.0"], "zeroconf": ["_system-bridge._tcp.local."] } diff --git a/homeassistant/components/system_bridge/media_player.py b/homeassistant/components/system_bridge/media_player.py index 088c57573f1..ea9e8ab070d 100644 --- a/homeassistant/components/system_bridge/media_player.py +++ b/homeassistant/components/system_bridge/media_player.py @@ -4,10 +4,7 @@ from __future__ import annotations import datetime as dt from typing import Final -from systembridgeconnector.models.media_control import ( - Action as MediaAction, - MediaControl, -) +from systembridgemodels.media_control import Action as MediaAction, MediaControl from homeassistant.components.media_player import ( MediaPlayerDeviceClass, @@ -22,9 +19,9 @@ from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SystemBridgeEntity from .const import DOMAIN from .coordinator import SystemBridgeCoordinatorData, SystemBridgeDataUpdateCoordinator +from .entity import SystemBridgeEntity STATUS_CHANGING: Final[str] = "CHANGING" STATUS_STOPPED: Final[str] = "STOPPED" diff --git a/homeassistant/components/system_bridge/media_source.py b/homeassistant/components/system_bridge/media_source.py index 3186d74b15a..3423946f637 100644 --- a/homeassistant/components/system_bridge/media_source.py +++ b/homeassistant/components/system_bridge/media_source.py @@ -1,8 +1,8 @@ """System Bridge Media Source Implementation.""" from __future__ import annotations -from systembridgeconnector.models.media_directories import MediaDirectories -from systembridgeconnector.models.media_files import File as MediaFile, MediaFiles +from systembridgemodels.media_directories import MediaDirectories +from systembridgemodels.media_files import File as MediaFile, MediaFiles from homeassistant.components.media_player import MediaClass from homeassistant.components.media_source import MEDIA_CLASS_MAP, MEDIA_MIME_TYPES diff --git a/homeassistant/components/system_bridge/notify.py b/homeassistant/components/system_bridge/notify.py index 1ad071bf78f..f8c00789ae5 100644 --- a/homeassistant/components/system_bridge/notify.py +++ b/homeassistant/components/system_bridge/notify.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import Any -from systembridgeconnector.models.notification import Notification +from systembridgemodels.notification import Notification from homeassistant.components.notify import ( ATTR_DATA, diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index 151a6882e26..e3fd2c14654 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -28,9 +28,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import UNDEFINED, StateType from homeassistant.util.dt import utcnow -from . import SystemBridgeEntity from .const import DOMAIN from .coordinator import SystemBridgeCoordinatorData, SystemBridgeDataUpdateCoordinator +from .entity import SystemBridgeEntity ATTR_AVAILABLE: Final = "available" ATTR_FILESYSTEM: Final = "filesystem" @@ -219,6 +219,13 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( icon="mdi:devices", value=lambda data: f"{data.system.platform} {data.system.platform_version}", ), + SystemBridgeSensorEntityDescription( + key="processes_count", + translation_key="processes", + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:counter", + value=lambda data: int(data.processes.count), + ), SystemBridgeSensorEntityDescription( key="processes_load", translation_key="load", diff --git a/homeassistant/components/system_bridge/strings.json b/homeassistant/components/system_bridge/strings.json index 4df539f11d4..d99a2cf4588 100644 --- a/homeassistant/components/system_bridge/strings.json +++ b/homeassistant/components/system_bridge/strings.json @@ -65,6 +65,9 @@ "os": { "name": "Operating system" }, + "processes": { + "name": "Processes" + }, "load": { "name": "Load" }, diff --git a/homeassistant/components/system_bridge/update.py b/homeassistant/components/system_bridge/update.py new file mode 100644 index 00000000000..5f667fad30d --- /dev/null +++ b/homeassistant/components/system_bridge/update.py @@ -0,0 +1,65 @@ +"""Support for System Bridge updates.""" +from __future__ import annotations + +from homeassistant.components.update import UpdateEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import SystemBridgeDataUpdateCoordinator +from .entity import SystemBridgeEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up System Bridge update based on a config entry.""" + coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + [ + SystemBridgeUpdateEntity( + coordinator, + entry.data[CONF_PORT], + ), + ] + ) + + +class SystemBridgeUpdateEntity(SystemBridgeEntity, UpdateEntity): + """Defines a System Bridge update entity.""" + + _attr_has_entity_name = True + _attr_title = "System Bridge" + + def __init__( + self, + coordinator: SystemBridgeDataUpdateCoordinator, + api_port: int, + ) -> None: + """Initialize.""" + super().__init__( + coordinator, + api_port, + "update", + ) + self._attr_name = coordinator.data.system.hostname + + @property + def installed_version(self) -> str | None: + """Version installed and in use.""" + return self.coordinator.data.system.version + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + return self.coordinator.data.system.version_latest + + @property + def release_url(self) -> str | None: + """URL to the full release notes of the latest version available.""" + return f"https://github.com/timmo001/system-bridge/releases/tag/{self.coordinator.data.system.version_latest}" diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index e82083f73ec..59b0fa995e4 100644 --- a/homeassistant/components/tag/__init__.py +++ b/homeassistant/components/tag/__init__.py @@ -118,10 +118,19 @@ async def async_scan_tag( if DOMAIN not in hass.config.components: raise HomeAssistantError("tag component has not been set up.") - hass.bus.async_fire( - EVENT_TAG_SCANNED, {TAG_ID: tag_id, DEVICE_ID: device_id}, context=context - ) helper = hass.data[DOMAIN][TAGS] + + # Get name from helper, default value None if not present in data + tag_name = None + if tag_data := helper.data.get(tag_id): + tag_name = tag_data.get(CONF_NAME) + + hass.bus.async_fire( + EVENT_TAG_SCANNED, + {TAG_ID: tag_id, CONF_NAME: tag_name, DEVICE_ID: device_id}, + context=context, + ) + if tag_id in helper.data: await helper.async_update_item(tag_id, {LAST_SCANNED: dt_util.utcnow()}) else: diff --git a/homeassistant/components/tailscale/binary_sensor.py b/homeassistant/components/tailscale/binary_sensor.py index ecc561f0355..ee1c682c559 100644 --- a/homeassistant/components/tailscale/binary_sensor.py +++ b/homeassistant/components/tailscale/binary_sensor.py @@ -20,20 +20,13 @@ from . import TailscaleEntity from .const import DOMAIN -@dataclass -class TailscaleBinarySensorEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(kw_only=True) +class TailscaleBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes a Tailscale binary sensor entity.""" is_on_fn: Callable[[TailscaleDevice], bool | None] -@dataclass -class TailscaleBinarySensorEntityDescription( - BinarySensorEntityDescription, TailscaleBinarySensorEntityDescriptionMixin -): - """Describes a Tailscale binary sensor entity.""" - - BINARY_SENSORS: tuple[TailscaleBinarySensorEntityDescription, ...] = ( TailscaleBinarySensorEntityDescription( key="update_available", diff --git a/homeassistant/components/tailscale/sensor.py b/homeassistant/components/tailscale/sensor.py index 75dca4ed840..f5850848c8c 100644 --- a/homeassistant/components/tailscale/sensor.py +++ b/homeassistant/components/tailscale/sensor.py @@ -21,20 +21,13 @@ from . import TailscaleEntity from .const import DOMAIN -@dataclass -class TailscaleSensorEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(kw_only=True) +class TailscaleSensorEntityDescription(SensorEntityDescription): + """Describes a Tailscale sensor entity.""" value_fn: Callable[[TailscaleDevice], datetime | str | None] -@dataclass -class TailscaleSensorEntityDescription( - SensorEntityDescription, TailscaleSensorEntityDescriptionMixin -): - """Describes a Tailscale sensor entity.""" - - SENSORS: tuple[TailscaleSensorEntityDescription, ...] = ( TailscaleSensorEntityDescription( key="expires", diff --git a/homeassistant/components/tankerkoenig/strings.json b/homeassistant/components/tankerkoenig/strings.json index 43d444b2c46..7017c6e5fed 100644 --- a/homeassistant/components/tankerkoenig/strings.json +++ b/homeassistant/components/tankerkoenig/strings.json @@ -47,18 +47,113 @@ "entity": { "binary_sensor": { "status": { - "name": "Status" + "name": "Status", + "state_attributes": { + "latitude": { + "name": "[%key:common::config_flow::data::latitude%]" + }, + "longitude": { + "name": "[%key:common::config_flow::data::longitude%]" + } + } } }, "sensor": { "e5": { - "name": "Super" + "name": "Super", + "state_attributes": { + "brand": { + "name": "Brand" + }, + "fuel_type": { + "name": "Fuel type" + }, + "station_name": { + "name": "Station name" + }, + "street": { + "name": "Street" + }, + "house_number": { + "name": "House number" + }, + "postcode": { + "name": "Postal code" + }, + "city": { + "name": "City" + }, + "latitude": { + "name": "[%key:common::config_flow::data::latitude%]" + }, + "longitude": { + "name": "[%key:common::config_flow::data::longitude%]" + } + } }, "e10": { - "name": "Super E10" + "name": "Super E10", + "state_attributes": { + "brand": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::brand::name%]" + }, + "fuel_type": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::fuel_type::name%]" + }, + "station_name": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::station_name::name%]" + }, + "street": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::street::name%]" + }, + "house_number": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::house_number::name%]" + }, + "postcode": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::postcode::name%]" + }, + "city": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::city::name%]" + }, + "latitude": { + "name": "[%key:common::config_flow::data::latitude%]" + }, + "longitude": { + "name": "[%key:common::config_flow::data::longitude%]" + } + } }, "diesel": { - "name": "Diesel" + "name": "Diesel", + "state_attributes": { + "brand": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::brand::name%]" + }, + "fuel_type": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::fuel_type::name%]" + }, + "station_name": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::station_name::name%]" + }, + "street": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::street::name%]" + }, + "house_number": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::house_number::name%]" + }, + "postcode": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::postcode::name%]" + }, + "city": { + "name": "[%key:component::tankerkoenig::entity::sensor::e5::state_attributes::city::name%]" + }, + "latitude": { + "name": "[%key:common::config_flow::data::latitude%]" + }, + "longitude": { + "name": "[%key:common::config_flow::data::longitude%]" + } + } } } } diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 76677c3813e..7d150e95977 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -786,6 +786,7 @@ class TelegramNotificationService: photo=file_content, caption=kwargs.get(ATTR_CAPTION), disable_notification=params[ATTR_DISABLE_NOTIF], + reply_to_message_id=params[ATTR_REPLY_TO_MSGID], reply_markup=params[ATTR_REPLYMARKUP], timeout=params[ATTR_TIMEOUT], parse_mode=params[ATTR_PARSER], @@ -799,6 +800,7 @@ class TelegramNotificationService: chat_id=chat_id, sticker=file_content, disable_notification=params[ATTR_DISABLE_NOTIF], + reply_to_message_id=params[ATTR_REPLY_TO_MSGID], reply_markup=params[ATTR_REPLYMARKUP], timeout=params[ATTR_TIMEOUT], ) @@ -812,6 +814,7 @@ class TelegramNotificationService: video=file_content, caption=kwargs.get(ATTR_CAPTION), disable_notification=params[ATTR_DISABLE_NOTIF], + reply_to_message_id=params[ATTR_REPLY_TO_MSGID], reply_markup=params[ATTR_REPLYMARKUP], timeout=params[ATTR_TIMEOUT], parse_mode=params[ATTR_PARSER], @@ -825,6 +828,7 @@ class TelegramNotificationService: document=file_content, caption=kwargs.get(ATTR_CAPTION), disable_notification=params[ATTR_DISABLE_NOTIF], + reply_to_message_id=params[ATTR_REPLY_TO_MSGID], reply_markup=params[ATTR_REPLYMARKUP], timeout=params[ATTR_TIMEOUT], parse_mode=params[ATTR_PARSER], @@ -838,6 +842,7 @@ class TelegramNotificationService: voice=file_content, caption=kwargs.get(ATTR_CAPTION), disable_notification=params[ATTR_DISABLE_NOTIF], + reply_to_message_id=params[ATTR_REPLY_TO_MSGID], reply_markup=params[ATTR_REPLYMARKUP], timeout=params[ATTR_TIMEOUT], ) @@ -850,6 +855,7 @@ class TelegramNotificationService: animation=file_content, caption=kwargs.get(ATTR_CAPTION), disable_notification=params[ATTR_DISABLE_NOTIF], + reply_to_message_id=params[ATTR_REPLY_TO_MSGID], reply_markup=params[ATTR_REPLYMARKUP], timeout=params[ATTR_TIMEOUT], parse_mode=params[ATTR_PARSER], @@ -872,6 +878,7 @@ class TelegramNotificationService: chat_id=chat_id, sticker=stickerid, disable_notification=params[ATTR_DISABLE_NOTIF], + reply_to_message_id=params[ATTR_REPLY_TO_MSGID], reply_markup=params[ATTR_REPLYMARKUP], timeout=params[ATTR_TIMEOUT], ) @@ -895,6 +902,7 @@ class TelegramNotificationService: latitude=latitude, longitude=longitude, disable_notification=params[ATTR_DISABLE_NOTIF], + reply_to_message_id=params[ATTR_REPLY_TO_MSGID], timeout=params[ATTR_TIMEOUT], ) @@ -923,6 +931,7 @@ class TelegramNotificationService: allows_multiple_answers=allows_multiple_answers, open_period=openperiod, disable_notification=params[ATTR_DISABLE_NOTIF], + reply_to_message_id=params[ATTR_REPLY_TO_MSGID], timeout=params[ATTR_TIMEOUT], ) diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index 94d1eee1b55..1587f754508 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -34,7 +34,6 @@ send_message: min: 1 max: 3600 unit_of_measurement: seconds - keyboard: example: '["/command1, /command2", "/command3"]' selector: @@ -50,6 +49,10 @@ send_message: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_photo: fields: @@ -117,6 +120,10 @@ send_photo: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_sticker: fields: @@ -177,6 +184,10 @@ send_sticker: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_animation: fields: @@ -240,6 +251,14 @@ send_animation: ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' selector: object: + message_tag: + example: "msg_to_edit" + selector: + text: + reply_to_message_id: + selector: + number: + mode: box send_video: fields: @@ -307,6 +326,10 @@ send_video: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_voice: fields: @@ -367,6 +390,10 @@ send_voice: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_document: fields: @@ -434,6 +461,10 @@ send_document: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_location: fields: @@ -480,6 +511,10 @@ send_location: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_poll: fields: @@ -516,6 +551,14 @@ send_poll: min: 1 max: 3600 unit_of_measurement: seconds + message_tag: + example: "msg_to_edit" + selector: + text: + reply_to_message_id: + selector: + number: + mode: box edit_message: fields: diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 4dfe0a28d01..de5de685409 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -42,7 +42,11 @@ }, "message_tag": { "name": "Message tag", - "description": "Tag for sent message. In telegram_sent event data: {{trigger.event.data.message_tag}}." + "description": "Tag for sent message." + }, + "reply_to_message_id": { + "name": "Reply to message id", + "description": "Mark the message as a reply to a previous message." } } }, @@ -105,6 +109,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -163,6 +171,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -221,6 +233,14 @@ "inline_keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::description%]" + }, + "message_tag": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -283,6 +303,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -341,6 +365,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -403,6 +431,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -441,6 +473,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -479,6 +515,14 @@ "timeout": { "name": "Timeout", "description": "Timeout for send poll. Will help with timeout errors (poor internet connection, etc)." + }, + "message_tag": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, diff --git a/homeassistant/components/tellduslive/strings.json b/homeassistant/components/tellduslive/strings.json index 1dbea7a0e6c..16c847f0077 100644 --- a/homeassistant/components/tellduslive/strings.json +++ b/homeassistant/components/tellduslive/strings.json @@ -18,7 +18,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]" }, - "title": "Pick endpoint." + "data_description": { + "host": "Hostname or IP address to Tellstick Net or Tellstick ZNet for Local API." + } } } }, diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 22919ac9e70..d52dc0cf166 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -34,8 +34,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.error(err) return - conf = await conf_util.async_process_component_config( - hass, unprocessed_conf, await async_get_integration(hass, DOMAIN) + integration = await async_get_integration(hass, DOMAIN) + conf = await conf_util.async_process_component_and_handle_errors( + hass, unprocessed_conf, integration ) if conf is None: diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 3329f185f08..9da43082d2b 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -10,10 +10,13 @@ from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN -from homeassistant.config import async_log_exception, config_without_domain +from homeassistant.config import async_log_schema_error, config_without_domain from homeassistant.const import CONF_BINARY_SENSORS, CONF_SENSORS, CONF_UNIQUE_ID +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger import async_validate_trigger_config +from homeassistant.helpers.typing import ConfigType +from homeassistant.setup import async_notify_setup_error from . import ( binary_sensor as binary_sensor_platform, @@ -64,7 +67,7 @@ CONFIG_SECTION_SCHEMA = vol.Schema( ) -async def async_validate_config(hass, config): +async def async_validate_config(hass: HomeAssistant, config: ConfigType) -> ConfigType: """Validate config.""" if DOMAIN not in config: return config @@ -80,7 +83,8 @@ async def async_validate_config(hass, config): hass, cfg[CONF_TRIGGER] ) except vol.Invalid as err: - async_log_exception(err, DOMAIN, cfg, hass) + async_log_schema_error(err, DOMAIN, cfg, hass) + async_notify_setup_error(hass, DOMAIN) continue legacy_warn_printed = False diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index d39fa56775a..8aeede42552 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -282,15 +282,6 @@ class TemplateFan(TemplateEntity, FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset_mode of the fan.""" - if self.preset_modes and preset_mode not in self.preset_modes: - _LOGGER.error( - "Received invalid preset_mode: %s for entity %s. Expected: %s", - preset_mode, - self.entity_id, - self.preset_modes, - ) - return - self._preset_mode = preset_mode if self._set_preset_mode_script: diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index b3f276240b5..89c4826f1e6 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -11,6 +11,9 @@ from homeassistant.components.light import ( ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + ATTR_RGBWW_COLOR, ATTR_TRANSITION, ENTITY_ID_FORMAT, ColorMode, @@ -46,8 +49,18 @@ from .template_entity import ( _LOGGER = logging.getLogger(__name__) _VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"] +# Legacy CONF_COLOR_ACTION = "set_color" CONF_COLOR_TEMPLATE = "color_template" + +CONF_HS_ACTION = "set_hs" +CONF_HS_TEMPLATE = "hs_template" +CONF_RGB_ACTION = "set_rgb" +CONF_RGB_TEMPLATE = "rgb_template" +CONF_RGBW_ACTION = "set_rgbw" +CONF_RGBW_TEMPLATE = "rgbw_template" +CONF_RGBWW_ACTION = "set_rgbww" +CONF_RGBWW_TEMPLATE = "rgbww_template" CONF_EFFECT_ACTION = "set_effect" CONF_EFFECT_LIST_TEMPLATE = "effect_list_template" CONF_EFFECT_TEMPLATE = "effect_template" @@ -67,8 +80,16 @@ LIGHT_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { - vol.Optional(CONF_COLOR_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_COLOR_TEMPLATE): cv.template, + vol.Exclusive(CONF_COLOR_ACTION, "hs_legacy_action"): cv.SCRIPT_SCHEMA, + vol.Exclusive(CONF_COLOR_TEMPLATE, "hs_legacy_template"): cv.template, + vol.Exclusive(CONF_HS_ACTION, "hs_legacy_action"): cv.SCRIPT_SCHEMA, + vol.Exclusive(CONF_HS_TEMPLATE, "hs_legacy_template"): cv.template, + vol.Optional(CONF_RGB_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_RGB_TEMPLATE): cv.template, + vol.Optional(CONF_RGBW_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_RGBW_TEMPLATE): cv.template, + vol.Optional(CONF_RGBWW_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_RGBWW_TEMPLATE): cv.template, vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA, vol.Inclusive(CONF_EFFECT_LIST_TEMPLATE, "effect"): cv.template, vol.Inclusive(CONF_EFFECT_TEMPLATE, "effect"): cv.template, @@ -166,6 +187,22 @@ class LightTemplate(TemplateEntity, LightEntity): if (color_action := config.get(CONF_COLOR_ACTION)) is not None: self._color_script = Script(hass, color_action, friendly_name, DOMAIN) self._color_template = config.get(CONF_COLOR_TEMPLATE) + self._hs_script = None + if (hs_action := config.get(CONF_HS_ACTION)) is not None: + self._hs_script = Script(hass, hs_action, friendly_name, DOMAIN) + self._hs_template = config.get(CONF_HS_TEMPLATE) + self._rgb_script = None + if (rgb_action := config.get(CONF_RGB_ACTION)) is not None: + self._rgb_script = Script(hass, rgb_action, friendly_name, DOMAIN) + self._rgb_template = config.get(CONF_RGB_TEMPLATE) + self._rgbw_script = None + if (rgbw_action := config.get(CONF_RGBW_ACTION)) is not None: + self._rgbw_script = Script(hass, rgbw_action, friendly_name, DOMAIN) + self._rgbw_template = config.get(CONF_RGBW_TEMPLATE) + self._rgbww_script = None + if (rgbww_action := config.get(CONF_RGBWW_ACTION)) is not None: + self._rgbww_script = Script(hass, rgbww_action, friendly_name, DOMAIN) + self._rgbww_template = config.get(CONF_RGBWW_TEMPLATE) self._effect_script = None if (effect_action := config.get(CONF_EFFECT_ACTION)) is not None: self._effect_script = Script(hass, effect_action, friendly_name, DOMAIN) @@ -178,24 +215,39 @@ class LightTemplate(TemplateEntity, LightEntity): self._state = False self._brightness = None self._temperature = None - self._color = None + self._hs_color = None + self._rgb_color = None + self._rgbw_color = None + self._rgbww_color = None self._effect = None self._effect_list = None - self._fixed_color_mode = None + self._color_mode = None self._max_mireds = None self._min_mireds = None self._supports_transition = False + self._supported_color_modes = None color_modes = {ColorMode.ONOFF} if self._level_script is not None: color_modes.add(ColorMode.BRIGHTNESS) if self._temperature_script is not None: color_modes.add(ColorMode.COLOR_TEMP) + if self._hs_script is not None: + color_modes.add(ColorMode.HS) if self._color_script is not None: color_modes.add(ColorMode.HS) + if self._rgb_script is not None: + color_modes.add(ColorMode.RGB) + if self._rgbw_script is not None: + color_modes.add(ColorMode.RGBW) + if self._rgbww_script is not None: + color_modes.add(ColorMode.RGBWW) + self._supported_color_modes = filter_supported_color_modes(color_modes) + if len(self._supported_color_modes) > 1: + self._color_mode = ColorMode.UNKNOWN if len(self._supported_color_modes) == 1: - self._fixed_color_mode = next(iter(self._supported_color_modes)) + self._color_mode = next(iter(self._supported_color_modes)) self._attr_supported_features = LightEntityFeature(0) if self._effect_script is not None: @@ -232,7 +284,22 @@ class LightTemplate(TemplateEntity, LightEntity): @property def hs_color(self) -> tuple[float, float] | None: """Return the hue and saturation color value [float, float].""" - return self._color + return self._hs_color + + @property + def rgb_color(self) -> tuple[int, int, int] | None: + """Return the rgb color value.""" + return self._rgb_color + + @property + def rgbw_color(self) -> tuple[int, int, int, int] | None: + """Return the rgbw color value.""" + return self._rgbw_color + + @property + def rgbww_color(self) -> tuple[int, int, int, int, int] | None: + """Return the rgbww color value.""" + return self._rgbww_color @property def effect(self) -> str | None: @@ -247,12 +314,7 @@ class LightTemplate(TemplateEntity, LightEntity): @property def color_mode(self): """Return current color mode.""" - if self._fixed_color_mode: - return self._fixed_color_mode - # Support for ct + hs, prioritize hs - if self._color is not None: - return ColorMode.HS - return ColorMode.COLOR_TEMP + return self._color_mode @property def supported_color_modes(self): @@ -305,10 +367,42 @@ class LightTemplate(TemplateEntity, LightEntity): ) if self._color_template: self.add_template_attribute( - "_color", + "_hs_color", self._color_template, None, - self._update_color, + self._update_hs, + none_on_template_error=True, + ) + if self._hs_template: + self.add_template_attribute( + "_hs_color", + self._hs_template, + None, + self._update_hs, + none_on_template_error=True, + ) + if self._rgb_template: + self.add_template_attribute( + "_rgb_color", + self._rgb_template, + None, + self._update_rgb, + none_on_template_error=True, + ) + if self._rgbw_template: + self.add_template_attribute( + "_rgbw_color", + self._rgbw_template, + None, + self._update_rgbw, + none_on_template_error=True, + ) + if self._rgbww_template: + self.add_template_attribute( + "_rgbww_color", + self._rgbww_template, + None, + self._update_rgbww, none_on_template_error=True, ) if self._effect_list_template: @@ -337,7 +431,7 @@ class LightTemplate(TemplateEntity, LightEntity): ) super()._async_setup_templates() - async def async_turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: # noqa: C901 """Turn the light on.""" optimistic_set = False # set optimistic states @@ -357,19 +451,88 @@ class LightTemplate(TemplateEntity, LightEntity): "Optimistically setting color temperature to %s", kwargs[ATTR_COLOR_TEMP], ) + self._color_mode = ColorMode.COLOR_TEMP self._temperature = kwargs[ATTR_COLOR_TEMP] - if self._color_template is None: - self._color = None + if self._hs_template is None and self._color_template is None: + self._hs_color = None + if self._rgb_template is None: + self._rgb_color = None + if self._rgbw_template is None: + self._rgbw_color = None + if self._rgbww_template is None: + self._rgbww_color = None optimistic_set = True - if self._color_template is None and ATTR_HS_COLOR in kwargs: + if ( + self._hs_template is None + and self._color_template is None + and ATTR_HS_COLOR in kwargs + ): _LOGGER.debug( - "Optimistically setting color to %s", + "Optimistically setting hs color to %s", kwargs[ATTR_HS_COLOR], ) - self._color = kwargs[ATTR_HS_COLOR] + self._color_mode = ColorMode.HS + self._hs_color = kwargs[ATTR_HS_COLOR] if self._temperature_template is None: self._temperature = None + if self._rgb_template is None: + self._rgb_color = None + if self._rgbw_template is None: + self._rgbw_color = None + if self._rgbww_template is None: + self._rgbww_color = None + optimistic_set = True + + if self._rgb_template is None and ATTR_RGB_COLOR in kwargs: + _LOGGER.debug( + "Optimistically setting rgb color to %s", + kwargs[ATTR_RGB_COLOR], + ) + self._color_mode = ColorMode.RGB + self._rgb_color = kwargs[ATTR_RGB_COLOR] + if self._temperature_template is None: + self._temperature = None + if self._hs_template is None and self._color_template is None: + self._hs_color = None + if self._rgbw_template is None: + self._rgbw_color = None + if self._rgbww_template is None: + self._rgbww_color = None + optimistic_set = True + + if self._rgbw_template is None and ATTR_RGBW_COLOR in kwargs: + _LOGGER.debug( + "Optimistically setting rgbw color to %s", + kwargs[ATTR_RGBW_COLOR], + ) + self._color_mode = ColorMode.RGBW + self._rgbw_color = kwargs[ATTR_RGBW_COLOR] + if self._temperature_template is None: + self._temperature = None + if self._hs_template is None and self._color_template is None: + self._hs_color = None + if self._rgb_template is None: + self._rgb_color = None + if self._rgbww_template is None: + self._rgbww_color = None + optimistic_set = True + + if self._rgbww_template is None and ATTR_RGBWW_COLOR in kwargs: + _LOGGER.debug( + "Optimistically setting rgbww color to %s", + kwargs[ATTR_RGBWW_COLOR], + ) + self._color_mode = ColorMode.RGBWW + self._rgbww_color = kwargs[ATTR_RGBWW_COLOR] + if self._temperature_template is None: + self._temperature = None + if self._hs_template is None and self._color_template is None: + self._hs_color = None + if self._rgb_template is None: + self._rgb_color = None + if self._rgbw_template is None: + self._rgbw_color = None optimistic_set = True common_params = {} @@ -413,6 +576,58 @@ class LightTemplate(TemplateEntity, LightEntity): await self.async_run_script( self._color_script, run_variables=common_params, context=self._context ) + elif ATTR_HS_COLOR in kwargs and self._hs_script: + hs_value = kwargs[ATTR_HS_COLOR] + common_params["hs"] = hs_value + common_params["h"] = int(hs_value[0]) + common_params["s"] = int(hs_value[1]) + + await self.async_run_script( + self._hs_script, run_variables=common_params, context=self._context + ) + elif ATTR_RGBWW_COLOR in kwargs and self._rgbww_script: + rgbww_value = kwargs[ATTR_RGBWW_COLOR] + common_params["rgbww"] = rgbww_value + common_params["rgb"] = ( + int(rgbww_value[0]), + int(rgbww_value[1]), + int(rgbww_value[2]), + ) + common_params["r"] = int(rgbww_value[0]) + common_params["g"] = int(rgbww_value[1]) + common_params["b"] = int(rgbww_value[2]) + common_params["cw"] = int(rgbww_value[3]) + common_params["ww"] = int(rgbww_value[4]) + + await self.async_run_script( + self._rgbww_script, run_variables=common_params, context=self._context + ) + elif ATTR_RGBW_COLOR in kwargs and self._rgbw_script: + rgbw_value = kwargs[ATTR_RGBW_COLOR] + common_params["rgbw"] = rgbw_value + common_params["rgb"] = ( + int(rgbw_value[0]), + int(rgbw_value[1]), + int(rgbw_value[2]), + ) + common_params["r"] = int(rgbw_value[0]) + common_params["g"] = int(rgbw_value[1]) + common_params["b"] = int(rgbw_value[2]) + common_params["w"] = int(rgbw_value[3]) + + await self.async_run_script( + self._rgbw_script, run_variables=common_params, context=self._context + ) + elif ATTR_RGB_COLOR in kwargs and self._rgb_script: + rgb_value = kwargs[ATTR_RGB_COLOR] + common_params["rgb"] = rgb_value + common_params["r"] = int(rgb_value[0]) + common_params["g"] = int(rgb_value[1]) + common_params["b"] = int(rgb_value[2]) + + await self.async_run_script( + self._rgb_script, run_variables=common_params, context=self._context + ) elif ATTR_BRIGHTNESS in kwargs and self._level_script: await self.async_run_script( self._level_script, run_variables=common_params, context=self._context @@ -560,18 +775,19 @@ class LightTemplate(TemplateEntity, LightEntity): " this light, or 'None'" ) self._temperature = None + self._color_mode = ColorMode.COLOR_TEMP @callback - def _update_color(self, render): - """Update the hs_color from the template.""" + def _update_hs(self, render): + """Update the color from the template.""" if render is None: - self._color = None + self._hs_color = None return h_str = s_str = None if isinstance(render, str): if render in ("None", ""): - self._color = None + self._hs_color = None return h_str, s_str = map( float, render.replace("(", "").replace(")", "").split(",", 1) @@ -582,10 +798,12 @@ class LightTemplate(TemplateEntity, LightEntity): if ( h_str is not None and s_str is not None + and isinstance(h_str, (int, float)) + and isinstance(s_str, (int, float)) and 0 <= h_str <= 360 and 0 <= s_str <= 100 ): - self._color = (h_str, s_str) + self._hs_color = (h_str, s_str) elif h_str is not None and s_str is not None: _LOGGER.error( ( @@ -596,12 +814,151 @@ class LightTemplate(TemplateEntity, LightEntity): s_str, self.entity_id, ) - self._color = None + self._hs_color = None else: _LOGGER.error( "Received invalid hs_color : (%s) for entity %s", render, self.entity_id ) - self._color = None + self._hs_color = None + self._color_mode = ColorMode.HS + + @callback + def _update_rgb(self, render): + """Update the color from the template.""" + if render is None: + self._rgb_color = None + return + + r_int = g_int = b_int = None + if isinstance(render, str): + if render in ("None", ""): + self._rgb_color = None + return + cleanup_char = ["(", ")", "[", "]", " "] + for char in cleanup_char: + render = render.replace(char, "") + r_int, g_int, b_int = map(int, render.split(",", 3)) + elif isinstance(render, (list, tuple)) and len(render) == 3: + r_int, g_int, b_int = render + + if all( + value is not None and isinstance(value, (int, float)) and 0 <= value <= 255 + for value in (r_int, g_int, b_int) + ): + self._rgb_color = (r_int, g_int, b_int) + elif any( + isinstance(value, (int, float)) and not 0 <= value <= 255 + for value in (r_int, g_int, b_int) + ): + _LOGGER.error( + "Received invalid rgb_color : (%s, %s, %s) for entity %s. Expected: (0-255, 0-255, 0-255)", + r_int, + g_int, + b_int, + self.entity_id, + ) + self._rgb_color = None + else: + _LOGGER.error( + "Received invalid rgb_color : (%s) for entity %s", + render, + self.entity_id, + ) + self._rgb_color = None + self._color_mode = ColorMode.RGB + + @callback + def _update_rgbw(self, render): + """Update the color from the template.""" + if render is None: + self._rgbw_color = None + return + + r_int = g_int = b_int = w_int = None + if isinstance(render, str): + if render in ("None", ""): + self._rgb_color = None + return + cleanup_char = ["(", ")", "[", "]", " "] + for char in cleanup_char: + render = render.replace(char, "") + r_int, g_int, b_int, w_int = map(int, render.split(",", 4)) + elif isinstance(render, (list, tuple)) and len(render) == 4: + r_int, g_int, b_int, w_int = render + + if all( + value is not None and isinstance(value, (int, float)) and 0 <= value <= 255 + for value in (r_int, g_int, b_int, w_int) + ): + self._rgbw_color = (r_int, g_int, b_int, w_int) + elif any( + isinstance(value, (int, float)) and not 0 <= value <= 255 + for value in (r_int, g_int, b_int, w_int) + ): + _LOGGER.error( + "Received invalid rgb_color : (%s, %s, %s, %s) for entity %s. Expected: (0-255, 0-255, 0-255, 0-255)", + r_int, + g_int, + b_int, + w_int, + self.entity_id, + ) + self._rgbw_color = None + else: + _LOGGER.error( + "Received invalid rgb_color : (%s) for entity %s", + render, + self.entity_id, + ) + self._rgbw_color = None + self._color_mode = ColorMode.RGBW + + @callback + def _update_rgbww(self, render): + """Update the color from the template.""" + if render is None: + self._rgbww_color = None + return + + r_int = g_int = b_int = cw_int = ww_int = None + if isinstance(render, str): + if render in ("None", ""): + self._rgb_color = None + return + cleanup_char = ["(", ")", "[", "]", " "] + for char in cleanup_char: + render = render.replace(char, "") + r_int, g_int, b_int, cw_int, ww_int = map(int, render.split(",", 5)) + elif isinstance(render, (list, tuple)) and len(render) == 5: + r_int, g_int, b_int, cw_int, ww_int = render + + if all( + value is not None and isinstance(value, (int, float)) and 0 <= value <= 255 + for value in (r_int, g_int, b_int, cw_int, ww_int) + ): + self._rgbww_color = (r_int, g_int, b_int, cw_int, ww_int) + elif any( + isinstance(value, (int, float)) and not 0 <= value <= 255 + for value in (r_int, g_int, b_int, cw_int, ww_int) + ): + _LOGGER.error( + "Received invalid rgb_color : (%s, %s, %s, %s, %s) for entity %s. Expected: (0-255, 0-255, 0-255, 0-255)", + r_int, + g_int, + b_int, + cw_int, + ww_int, + self.entity_id, + ) + self._rgbww_color = None + else: + _LOGGER.error( + "Received invalid rgb_color : (%s) for entity %s", + render, + self.entity_id, + ) + self._rgbww_color = None + self._color_mode = ColorMode.RGBWW @callback def _update_max_mireds(self, render): diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 4e9149ebd07..0a00d1e79b4 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -57,7 +57,8 @@ from .template_entity import TemplateEntity, rewrite_common_legacy_to_modern_con from .trigger_entity import TriggerEntity CHECK_FORECAST_KEYS = ( - set().union(Forecast.__annotations__.keys()) + set() + .union(Forecast.__annotations__.keys()) # Manually add the forecast resulting attributes that only exists # as native_* in the Forecast definition .union(("apparent_temperature", "wind_gust_speed", "dew_point")) diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index 982894eb17c..97bac988d16 100644 --- a/homeassistant/components/tesla_wall_connector/strings.json +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -6,6 +6,9 @@ "title": "Configure Tesla Wall Connector", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Tesla Wall Connector." } } }, diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index b48760f773d..a0a07d3cb00 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -16,5 +16,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermopro", "iot_class": "local_push", - "requirements": ["thermopro-ble==0.4.5"] + "requirements": ["thermopro-ble==0.5.0"] } diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index f814fbffbd0..9c5d79cc0e0 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -38,7 +38,7 @@ class DatasetEntry: tlv: str created: datetime = dataclasses.field(default_factory=dt_util.utcnow) - id: str = dataclasses.field(default_factory=ulid_util.ulid) + id: str = dataclasses.field(default_factory=ulid_util.ulid_now) @property def channel(self) -> int | None: diff --git a/homeassistant/components/tibber/config_flow.py b/homeassistant/components/tibber/config_flow.py index fbd2345fb80..3fb426d6b11 100644 --- a/homeassistant/components/tibber/config_flow.py +++ b/homeassistant/components/tibber/config_flow.py @@ -19,6 +19,7 @@ DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) ERR_TIMEOUT = "timeout" ERR_CLIENT = "cannot_connect" ERR_TOKEN = "invalid_access_token" +TOKEN_URL = "https://developer.tibber.com/settings/access-token" class TibberConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -60,6 +61,7 @@ class TibberConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, + description_placeholders={"url": TOKEN_URL}, errors=errors, ) @@ -75,5 +77,6 @@ class TibberConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, + description_placeholders={"url": TOKEN_URL}, errors={}, ) diff --git a/homeassistant/components/tibber/strings.json b/homeassistant/components/tibber/strings.json index 8306f25f587..c7cef9f4657 100644 --- a/homeassistant/components/tibber/strings.json +++ b/homeassistant/components/tibber/strings.json @@ -13,7 +13,7 @@ "data": { "access_token": "[%key:common::config_flow::data::access_token%]" }, - "description": "Enter your access token from https://developer.tibber.com/settings/accesstoken" + "description": "Enter your access token from {url}" } } } diff --git a/homeassistant/components/tod/binary_sensor.py b/homeassistant/components/tod/binary_sensor.py index f72aa742f56..c3f2c75e07b 100644 --- a/homeassistant/components/tod/binary_sensor.py +++ b/homeassistant/components/tod/binary_sensor.py @@ -226,6 +226,21 @@ class TodSensor(BinarySensorEntity): self._time_after += self._after_offset self._time_before += self._before_offset + def _add_one_dst_aware_day(self, a_date: datetime, target_time: time) -> datetime: + """Add 24 hours (1 day) but account for DST.""" + tentative_new_date = a_date + timedelta(days=1) + tentative_new_date = dt_util.as_local(tentative_new_date) + tentative_new_date = tentative_new_date.replace( + hour=target_time.hour, minute=target_time.minute + ) + # The following call addresses missing time during DST jumps + return dt_util.find_next_time_expression_time( + tentative_new_date, + dt_util.parse_time_expression("*", 0, 59), + dt_util.parse_time_expression("*", 0, 59), + dt_util.parse_time_expression("*", 0, 23), + ) + def _turn_to_next_day(self) -> None: """Turn to to the next day.""" if TYPE_CHECKING: @@ -238,7 +253,9 @@ class TodSensor(BinarySensorEntity): self._time_after += self._after_offset else: # Offset is already there - self._time_after += timedelta(days=1) + self._time_after = self._add_one_dst_aware_day( + self._time_after, self._after + ) if _is_sun_event(self._before): self._time_before = get_astral_event_next( @@ -247,7 +264,9 @@ class TodSensor(BinarySensorEntity): self._time_before += self._before_offset else: # Offset is already there - self._time_before += timedelta(days=1) + self._time_before = self._add_one_dst_aware_day( + self._time_before, self._before + ) async def async_added_to_hass(self) -> None: """Call when entity about to be added to Home Assistant.""" diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index 968256ce3d9..c0e0303d76e 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -1,9 +1,10 @@ """The todo integration.""" +from collections.abc import Callable, Iterable import dataclasses import datetime import logging -from typing import Any +from typing import Any, final import voluptuous as vol @@ -11,7 +12,13 @@ from homeassistant.components import frontend, websocket_api from homeassistant.components.websocket_api import ERR_NOT_FOUND, ERR_NOT_SUPPORTED from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import ( + CALLBACK_TYPE, + HomeAssistant, + ServiceCall, + SupportsResponse, + callback, +) from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 @@ -21,8 +28,18 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util +from homeassistant.util.json import JsonValueType -from .const import DOMAIN, TodoItemStatus, TodoListEntityFeature +from .const import ( + ATTR_DESCRIPTION, + ATTR_DUE, + ATTR_DUE_DATE, + ATTR_DUE_DATETIME, + DOMAIN, + TodoItemStatus, + TodoListEntityFeature, +) _LOGGER = logging.getLogger(__name__) @@ -31,6 +48,63 @@ SCAN_INTERVAL = datetime.timedelta(seconds=60) ENTITY_ID_FORMAT = DOMAIN + ".{}" +@dataclasses.dataclass +class TodoItemFieldDescription: + """A description of To-do item fields and validation requirements.""" + + service_field: str + """Field name for service calls.""" + + todo_item_field: str + """Field name for TodoItem.""" + + validation: Callable[[Any], Any] + """Voluptuous validation function.""" + + required_feature: TodoListEntityFeature + """Entity feature that enables this field.""" + + +TODO_ITEM_FIELDS = [ + TodoItemFieldDescription( + service_field=ATTR_DUE_DATE, + validation=cv.date, + todo_item_field=ATTR_DUE, + required_feature=TodoListEntityFeature.SET_DUE_DATE_ON_ITEM, + ), + TodoItemFieldDescription( + service_field=ATTR_DUE_DATETIME, + validation=vol.All(cv.datetime, dt_util.as_local), + todo_item_field=ATTR_DUE, + required_feature=TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, + ), + TodoItemFieldDescription( + service_field=ATTR_DESCRIPTION, + validation=cv.string, + todo_item_field=ATTR_DESCRIPTION, + required_feature=TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM, + ), +] + +TODO_ITEM_FIELD_SCHEMA = { + vol.Optional(desc.service_field): desc.validation for desc in TODO_ITEM_FIELDS +} +TODO_ITEM_FIELD_VALIDATIONS = [cv.has_at_most_one_key(ATTR_DUE_DATE, ATTR_DUE_DATETIME)] + + +def _validate_supported_features( + supported_features: int | None, call_data: dict[str, Any] +) -> None: + """Validate service call fields against entity supported features.""" + for desc in TODO_ITEM_FIELDS: + if desc.service_field not in call_data: + continue + if not supported_features or not supported_features & desc.required_feature: + raise ValueError( + f"Entity does not support setting field '{desc.service_field}'" + ) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Todo entities.""" component = hass.data[DOMAIN] = EntityComponent[TodoListEntity]( @@ -39,14 +113,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: frontend.async_register_built_in_panel(hass, "todo", "todo", "mdi:clipboard-list") + websocket_api.async_register_command(hass, websocket_handle_subscribe_todo_items) websocket_api.async_register_command(hass, websocket_handle_todo_item_list) websocket_api.async_register_command(hass, websocket_handle_todo_item_move) component.async_register_entity_service( "add_item", - { - vol.Required("item"): vol.All(cv.string, vol.Length(min=1)), - }, + vol.All( + cv.make_entity_service_schema( + { + vol.Required("item"): vol.All(cv.string, vol.Length(min=1)), + **TODO_ITEM_FIELD_SCHEMA, + } + ), + *TODO_ITEM_FIELD_VALIDATIONS, + ), _async_add_todo_item, required_features=[TodoListEntityFeature.CREATE_TODO_ITEM], ) @@ -58,11 +139,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: vol.Required("item"): vol.All(cv.string, vol.Length(min=1)), vol.Optional("rename"): vol.All(cv.string, vol.Length(min=1)), vol.Optional("status"): vol.In( - {TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED} + {TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED}, ), + **TODO_ITEM_FIELD_SCHEMA, } ), - cv.has_at_least_one_key("rename", "status"), + *TODO_ITEM_FIELD_VALIDATIONS, + cv.has_at_least_one_key( + "rename", "status", *[desc.service_field for desc in TODO_ITEM_FIELDS] + ), ), _async_update_todo_item, required_features=[TodoListEntityFeature.UPDATE_TODO_ITEM], @@ -77,6 +162,25 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _async_remove_todo_items, required_features=[TodoListEntityFeature.DELETE_TODO_ITEM], ) + component.async_register_entity_service( + "get_items", + cv.make_entity_service_schema( + { + vol.Optional("status"): vol.All( + cv.ensure_list, + [vol.In({TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED})], + ), + } + ), + _async_get_todo_items, + supports_response=SupportsResponse.ONLY, + ) + component.async_register_entity_service( + "remove_completed_items", + {}, + _async_remove_completed_items, + required_features=[TodoListEntityFeature.DELETE_TODO_ITEM], + ) await component.async_setup(config) return True @@ -107,11 +211,26 @@ class TodoItem: status: TodoItemStatus | None = None """A status or confirmation of the To-do item.""" + due: datetime.date | datetime.datetime | None = None + """The date and time that a to-do is expected to be completed. + + This field may be a date or datetime depending whether the entity feature + DUE_DATE or DUE_DATETIME are set. + """ + + description: str | None = None + """A more complete description of than that provided by the summary. + + This field may be set when TodoListEntityFeature.DESCRIPTION is supported by + the entity. + """ + class TodoListEntity(Entity): """An entity that represents a To-do list.""" _attr_todo_items: list[TodoItem] | None = None + _update_listeners: list[Callable[[list[JsonValueType] | None], None]] | None = None @property def state(self) -> int | None: @@ -149,6 +268,102 @@ class TodoListEntity(Entity): """ raise NotImplementedError() + @final + @callback + def async_subscribe_updates( + self, + listener: Callable[[list[JsonValueType] | None], None], + ) -> CALLBACK_TYPE: + """Subscribe to To-do list item updates. + + Called by websocket API. + """ + if self._update_listeners is None: + self._update_listeners = [] + self._update_listeners.append(listener) + + @callback + def unsubscribe() -> None: + if self._update_listeners: + self._update_listeners.remove(listener) + + return unsubscribe + + @final + @callback + def async_update_listeners(self) -> None: + """Push updated To-do items to all listeners.""" + if not self._update_listeners: + return + + todo_items: list[JsonValueType] = [ + dataclasses.asdict(item) for item in self.todo_items or () + ] + for listener in self._update_listeners: + listener(todo_items) + + @callback + def _async_write_ha_state(self) -> None: + """Notify to-do item subscribers.""" + super()._async_write_ha_state() + self.async_update_listeners() + + +@websocket_api.websocket_command( + { + vol.Required("type"): "todo/item/subscribe", + vol.Required("entity_id"): cv.entity_domain(DOMAIN), + } +) +@websocket_api.async_response +async def websocket_handle_subscribe_todo_items( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Subscribe to To-do list item updates.""" + component: EntityComponent[TodoListEntity] = hass.data[DOMAIN] + entity_id: str = msg["entity_id"] + + if not (entity := component.get_entity(entity_id)): + connection.send_error( + msg["id"], + "invalid_entity_id", + f"To-do list entity not found: {entity_id}", + ) + return + + @callback + def todo_item_listener(todo_items: list[JsonValueType] | None) -> None: + """Push updated To-do list items to websocket.""" + connection.send_message( + websocket_api.event_message( + msg["id"], + { + "items": todo_items, + }, + ) + ) + + connection.subscriptions[msg["id"]] = entity.async_subscribe_updates( + todo_item_listener + ) + connection.send_result(msg["id"]) + + # Push an initial forecast update + entity.async_update_listeners() + + +def _api_items_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, str]: + """Convert CalendarEvent dataclass items to dictionary of attributes.""" + result: dict[str, str] = {} + for name, value in obj: + if value is None: + continue + if isinstance(value, (datetime.date, datetime.datetime)): + result[name] = value.isoformat() + else: + result[name] = str(value) + return result + @websocket_api.websocket_command( { @@ -173,7 +388,13 @@ async def websocket_handle_todo_item_list( items: list[TodoItem] = entity.todo_items or [] connection.send_message( websocket_api.result_message( - msg["id"], {"items": [dataclasses.asdict(item) for item in items]} + msg["id"], + { + "items": [ + dataclasses.asdict(item, dict_factory=_api_items_factory) + for item in items + ] + }, ) ) @@ -230,8 +451,17 @@ def _find_by_uid_or_summary( async def _async_add_todo_item(entity: TodoListEntity, call: ServiceCall) -> None: """Add an item to the To-do list.""" + _validate_supported_features(entity.supported_features, call.data) await entity.async_create_todo_item( - item=TodoItem(summary=call.data["item"], status=TodoItemStatus.NEEDS_ACTION) + item=TodoItem( + summary=call.data["item"], + status=TodoItemStatus.NEEDS_ACTION, + **{ + desc.todo_item_field: call.data[desc.service_field] + for desc in TODO_ITEM_FIELDS + if desc.service_field in call.data + }, + ) ) @@ -242,11 +472,20 @@ async def _async_update_todo_item(entity: TodoListEntity, call: ServiceCall) -> if not found: raise ValueError(f"Unable to find To-do item '{item}'") - update_item = TodoItem( - uid=found.uid, summary=call.data.get("rename"), status=call.data.get("status") - ) + _validate_supported_features(entity.supported_features, call.data) - await entity.async_update_todo_item(item=update_item) + await entity.async_update_todo_item( + item=TodoItem( + uid=found.uid, + summary=call.data.get("rename"), + status=call.data.get("status"), + **{ + desc.todo_item_field: call.data[desc.service_field] + for desc in TODO_ITEM_FIELDS + if desc.service_field in call.data + }, + ) + ) async def _async_remove_todo_items(entity: TodoListEntity, call: ServiceCall) -> None: @@ -258,3 +497,27 @@ async def _async_remove_todo_items(entity: TodoListEntity, call: ServiceCall) -> raise ValueError(f"Unable to find To-do item '{item}") uids.append(found.uid) await entity.async_delete_todo_items(uids=uids) + + +async def _async_get_todo_items( + entity: TodoListEntity, call: ServiceCall +) -> dict[str, Any]: + """Return items in the To-do list.""" + return { + "items": [ + dataclasses.asdict(item, dict_factory=_api_items_factory) + for item in entity.todo_items or () + if not (statuses := call.data.get("status")) or item.status in statuses + ] + } + + +async def _async_remove_completed_items(entity: TodoListEntity, _: ServiceCall) -> None: + """Remove all completed items from the To-do list.""" + uids = [ + item.uid + for item in entity.todo_items or () + if item.status == TodoItemStatus.COMPLETED and item.uid + ] + if uids: + await entity.async_delete_todo_items(uids=uids) diff --git a/homeassistant/components/todo/const.py b/homeassistant/components/todo/const.py index 5a8a6e54e8f..a605f9fcba2 100644 --- a/homeassistant/components/todo/const.py +++ b/homeassistant/components/todo/const.py @@ -4,6 +4,11 @@ from enum import IntFlag, StrEnum DOMAIN = "todo" +ATTR_DUE = "due" +ATTR_DUE_DATE = "due_date" +ATTR_DUE_DATETIME = "due_datetime" +ATTR_DESCRIPTION = "description" + class TodoListEntityFeature(IntFlag): """Supported features of the To-do List entity.""" @@ -12,6 +17,9 @@ class TodoListEntityFeature(IntFlag): DELETE_TODO_ITEM = 2 UPDATE_TODO_ITEM = 4 MOVE_TODO_ITEM = 8 + SET_DUE_DATE_ON_ITEM = 16 + SET_DUE_DATETIME_ON_ITEM = 32 + SET_DESCRIPTION_ON_ITEM = 64 class TodoItemStatus(StrEnum): diff --git a/homeassistant/components/todo/intent.py b/homeassistant/components/todo/intent.py new file mode 100644 index 00000000000..ba3545d8dfd --- /dev/null +++ b/homeassistant/components/todo/intent.py @@ -0,0 +1,54 @@ +"""Intents for the todo integration.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent + +from . import DOMAIN, TodoItem, TodoListEntity + +INTENT_LIST_ADD_ITEM = "HassListAddItem" + + +async def async_setup_intents(hass: HomeAssistant) -> None: + """Set up the todo intents.""" + intent.async_register(hass, ListAddItemIntent()) + + +class ListAddItemIntent(intent.IntentHandler): + """Handle ListAddItem intents.""" + + intent_type = INTENT_LIST_ADD_ITEM + slot_schema = {"item": cv.string, "name": cv.string} + + async def async_handle(self, intent_obj: intent.Intent): + """Handle the intent.""" + hass = intent_obj.hass + + slots = self.async_validate_slots(intent_obj.slots) + item = slots["item"]["value"] + list_name = slots["name"]["value"] + + component: EntityComponent[TodoListEntity] = hass.data[DOMAIN] + target_list: TodoListEntity | None = None + + # Find matching list + for list_state in intent.async_match_states( + hass, name=list_name, domains=[DOMAIN] + ): + target_list = component.get_entity(list_state.entity_id) + if target_list is not None: + break + + if target_list is None: + raise intent.IntentHandleError(f"No to-do list: {list_name}") + + assert target_list is not None + + # Add to list + await target_list.async_create_todo_item(TodoItem(item)) + + response = intent_obj.create_response() + response.response_type = intent.IntentResponseType.ACTION_DONE + return response diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index 1bdb8aca779..8ecc9e0ec86 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -1,3 +1,18 @@ +get_items: + target: + entity: + domain: todo + fields: + status: + example: "needs_action" + default: needs_action + selector: + select: + translation_key: status + options: + - needs_action + - completed + multiple: true add_item: target: entity: @@ -10,6 +25,18 @@ add_item: example: "Submit income tax return" selector: text: + due_date: + example: "2023-11-17" + selector: + date: + due_datetime: + example: "2023-11-17 13:30:00" + selector: + datetime: + description: + example: "A more complete description of the to-do item than that provided by the summary." + selector: + text: update_item: target: entity: @@ -34,6 +61,18 @@ update_item: options: - needs_action - completed + due_date: + example: "2023-11-17" + selector: + date: + due_datetime: + example: "2023-11-17 13:30:00" + selector: + datetime: + description: + example: "A more complete description of the to-do item than that provided by the summary." + selector: + text: remove_item: target: entity: @@ -45,3 +84,10 @@ remove_item: required: true selector: text: + +remove_completed_items: + target: + entity: + domain: todo + supported_features: + - todo.TodoListEntityFeature.DELETE_TODO_ITEM diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index 6ba8aaba1a5..3da921a8f47 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -6,6 +6,16 @@ } }, "services": { + "get_items": { + "name": "Get to-do list items", + "description": "Get items on a to-do list.", + "fields": { + "status": { + "name": "Status", + "description": "Only return to-do items with the specified statuses. Returns not completed actions by default." + } + } + }, "add_item": { "name": "Add to-do list item", "description": "Add a new to-do list item.", @@ -13,6 +23,18 @@ "item": { "name": "Item name", "description": "The name that represents the to-do item." + }, + "due_date": { + "name": "Due date", + "description": "The date the to-do item is expected to be completed." + }, + "due_datetime": { + "name": "Due date and time", + "description": "The date and time the to-do item is expected to be completed." + }, + "description": { + "name": "Description", + "description": "A more complete description of the to-do item than provided by the item name." } } }, @@ -31,9 +53,25 @@ "status": { "name": "Set status", "description": "A status or confirmation of the to-do item." + }, + "due_date": { + "name": "Due date", + "description": "The date the to-do item is expected to be completed." + }, + "due_datetime": { + "name": "Due date and time", + "description": "The date and time the to-do item is expected to be completed." + }, + "description": { + "name": "Description", + "description": "A more complete description of the to-do item than provided by the item name." } } }, + "remove_completed_items": { + "name": "Remove all completed to-do list items", + "description": "Remove all to-do list items that have been completed." + }, "remove_item": { "name": "Remove a to-do list item", "description": "Remove an existing to-do list item by its name.", diff --git a/homeassistant/components/todoist/config_flow.py b/homeassistant/components/todoist/config_flow.py index b8c79210dfb..94b4ad31826 100644 --- a/homeassistant/components/todoist/config_flow.py +++ b/homeassistant/components/todoist/config_flow.py @@ -16,7 +16,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -SETTINGS_URL = "https://todoist.com/app/settings/integrations" +SETTINGS_URL = "https://app.todoist.com/app/settings/integrations/developer" STEP_USER_DATA_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/todoist/todo.py b/homeassistant/components/todoist/todo.py index c0d3ec6e2ce..6231a6878ae 100644 --- a/homeassistant/components/todoist/todo.py +++ b/homeassistant/components/todoist/todo.py @@ -1,7 +1,8 @@ """A todo platform for Todoist.""" import asyncio -from typing import cast +import datetime +from typing import Any, cast from homeassistant.components.todo import ( TodoItem, @@ -13,6 +14,7 @@ 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 +from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import TodoistCoordinator @@ -30,6 +32,24 @@ async def async_setup_entry( ) +def _task_api_data(item: TodoItem) -> dict[str, Any]: + """Convert a TodoItem to the set of add or update arguments.""" + item_data: dict[str, Any] = {} + if summary := item.summary: + item_data["content"] = summary + if due := item.due: + if isinstance(due, datetime.datetime): + item_data["due"] = { + "date": due.date().isoformat(), + "datetime": due.isoformat(), + } + else: + item_data["due"] = {"date": due.isoformat()} + if description := item.description: + item_data["description"] = description + return item_data + + class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntity): """A Todoist TodoListEntity.""" @@ -37,6 +57,9 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM + | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) def __init__( @@ -62,15 +85,28 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit for task in self.coordinator.data: if task.project_id != self._project_id: continue + if task.parent_id is not None: + # Filter out sub-tasks until they are supported by the UI. + continue if task.is_completed: status = TodoItemStatus.COMPLETED else: status = TodoItemStatus.NEEDS_ACTION + due: datetime.date | datetime.datetime | None = None + if task_due := task.due: + if task_due.datetime: + due = dt_util.as_local( + datetime.datetime.fromisoformat(task_due.datetime) + ) + elif task_due.date: + due = datetime.date.fromisoformat(task_due.date) items.append( TodoItem( summary=task.content, uid=task.id, status=status, + due=due, + description=task.description or None, # Don't use empty string ) ) self._attr_todo_items = items @@ -81,7 +117,7 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit if item.status != TodoItemStatus.NEEDS_ACTION: raise ValueError("Only active tasks may be created.") await self.coordinator.api.add_task( - content=item.summary or "", + **_task_api_data(item), project_id=self._project_id, ) await self.coordinator.async_refresh() @@ -89,8 +125,8 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit async def async_update_todo_item(self, item: TodoItem) -> None: """Update a To-do item.""" uid: str = cast(str, item.uid) - if item.summary: - await self.coordinator.api.update_task(task_id=uid, content=item.summary) + if update_data := _task_api_data(item): + await self.coordinator.api.update_task(task_id=uid, **update_data) if item.status is not None: if item.status == TodoItemStatus.COMPLETED: await self.coordinator.api.close_task(task_id=uid) diff --git a/homeassistant/components/toon/strings.json b/homeassistant/components/toon/strings.json index 620a7f51113..ed29e77a58c 100644 --- a/homeassistant/components/toon/strings.json +++ b/homeassistant/components/toon/strings.json @@ -18,7 +18,11 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "no_agreements": "This account has no Toon displays.", - "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]" + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" } }, "services": { diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index e0ac41bdec6..162344f04ec 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -1,7 +1,7 @@ { "domain": "tplink", "name": "TP-Link Kasa Smart", - "codeowners": ["@rytilahti", "@thegardenmonkey"], + "codeowners": ["@rytilahti", "@thegardenmonkey", "@bdraco"], "config_flow": true, "dependencies": ["network"], "dhcp": [ diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 750d422cd0d..3b4024c07b4 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -6,6 +6,9 @@ "description": "If you leave the host empty, discovery will be used to find devices.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your TP-Link device." } }, "pick_device": { diff --git a/homeassistant/components/tplink_lte/__init__.py b/homeassistant/components/tplink_lte/__init__.py index 52ee5dfd980..378fd0a35d4 100644 --- a/homeassistant/components/tplink_lte/__init__.py +++ b/homeassistant/components/tplink_lte/__init__.py @@ -70,7 +70,7 @@ class LTEData: """Shared state.""" websession = attr.ib() - modem_data = attr.ib(init=False, factory=dict) + modem_data: dict[str, ModemData] = attr.ib(init=False, factory=dict) def get_modem_data(self, config): """Get the requested or the only modem_data value.""" diff --git a/homeassistant/components/tplink_omada/strings.json b/homeassistant/components/tplink_omada/strings.json index 6da32cd0c1a..04fa6d162d3 100644 --- a/homeassistant/components/tplink_omada/strings.json +++ b/homeassistant/components/tplink_omada/strings.json @@ -8,7 +8,9 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, - "title": "TP-Link Omada Controller", + "data_description": { + "host": "URL of the management interface of your TP-Link Omada controller." + }, "description": "Enter the connection details for the Omada controller. Cloud controllers aren't supported." }, "site": { diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index f1236a66700..3406997fd98 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -274,7 +274,8 @@ class TraccarScanner: """Import device data from Traccar.""" for position in self._positions: device = next( - (dev for dev in self._devices if dev.id == position.device_id), None + (dev for dev in self._devices if dev["id"] == position["deviceId"]), + None, ) if not device: @@ -282,36 +283,36 @@ class TraccarScanner: attr = { ATTR_TRACKER: "traccar", - ATTR_ADDRESS: position.address, - ATTR_SPEED: position.speed, - ATTR_ALTITUDE: position.altitude, - ATTR_MOTION: position.attributes.get("motion", False), - ATTR_TRACCAR_ID: device.id, + ATTR_ADDRESS: position["address"], + ATTR_SPEED: position["speed"], + ATTR_ALTITUDE: position["altitude"], + ATTR_MOTION: position["attributes"].get("motion", False), + ATTR_TRACCAR_ID: device["id"], ATTR_GEOFENCE: next( ( - geofence.name + geofence["name"] for geofence in self._geofences - if geofence.id in (device.geofence_ids or []) + if geofence["id"] in (position["geofenceIds"] or []) ), None, ), - ATTR_CATEGORY: device.category, - ATTR_STATUS: device.status, + ATTR_CATEGORY: device["category"], + ATTR_STATUS: device["status"], } skip_accuracy_filter = False for custom_attr in self._custom_attributes: - if device.attributes.get(custom_attr) is not None: - attr[custom_attr] = position.attributes[custom_attr] + if device["attributes"].get(custom_attr) is not None: + attr[custom_attr] = position["attributes"][custom_attr] if custom_attr in self._skip_accuracy_on: skip_accuracy_filter = True - if position.attributes.get(custom_attr) is not None: - attr[custom_attr] = position.attributes[custom_attr] + if position["attributes"].get(custom_attr) is not None: + attr[custom_attr] = position["attributes"][custom_attr] if custom_attr in self._skip_accuracy_on: skip_accuracy_filter = True - accuracy = position.accuracy or 0.0 + accuracy = position["accuracy"] or 0.0 if ( not skip_accuracy_filter and self._max_accuracy > 0 @@ -325,10 +326,10 @@ class TraccarScanner: continue await self._async_see( - dev_id=slugify(device.name), - gps=(position.latitude, position.longitude), + dev_id=slugify(device["name"]), + gps=(position["latitude"], position["longitude"]), gps_accuracy=accuracy, - battery=position.attributes.get("batteryLevel", -1), + battery=position["attributes"].get("batteryLevel", -1), attributes=attr, ) @@ -337,7 +338,7 @@ class TraccarScanner: # get_reports_events requires naive UTC datetimes as of 1.0.0 start_intervel = dt_util.utcnow().replace(tzinfo=None) events = await self._api.get_reports_events( - devices=[device.id for device in self._devices], + devices=[device["id"] for device in self._devices], start_time=start_intervel, end_time=start_intervel - self._scan_interval, event_types=self._event_types.keys(), @@ -345,20 +346,20 @@ class TraccarScanner: if events is not None: for event in events: self._hass.bus.async_fire( - f"traccar_{self._event_types.get(event.type)}", + f"traccar_{self._event_types.get(event['type'])}", { - "device_traccar_id": event.device_id, + "device_traccar_id": event["deviceId"], "device_name": next( ( - dev.name + dev["name"] for dev in self._devices - if dev.id == event.device_id + if dev["id"] == event["deviceId"] ), None, ), - "type": event.type, - "serverTime": event.event_time, - "attributes": event.attributes, + "type": event["type"], + "serverTime": event["eventTime"], + "attributes": event["attributes"], }, ) diff --git a/homeassistant/components/traccar/manifest.json b/homeassistant/components/traccar/manifest.json index 1c2cda69ffe..403ba3987ab 100644 --- a/homeassistant/components/traccar/manifest.json +++ b/homeassistant/components/traccar/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/traccar", "iot_class": "local_polling", "loggers": ["pytraccar"], - "requirements": ["pytraccar==1.0.0", "stringcase==1.2.0"] + "requirements": ["pytraccar==2.0.0", "stringcase==1.2.0"] } diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 300d7ebafc7..8dd0ed8e91b 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -24,11 +24,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( ATTR_ACTIVITY_LABEL, - ATTR_BUZZER, ATTR_CALORIES, ATTR_DAILY_GOAL, - ATTR_LED, - ATTR_LIVE_TRACKING, ATTR_MINUTES_ACTIVE, ATTR_MINUTES_DAY_SLEEP, ATTR_MINUTES_NIGHT_SLEEP, @@ -40,10 +37,12 @@ from .const import ( DOMAIN, RECONNECT_INTERVAL, SERVER_UNAVAILABLE, + SWITCH_KEY_MAP, TRACKABLES, TRACKER_ACTIVITY_STATUS_UPDATED, TRACKER_HARDWARE_STATUS_UPDATED, TRACKER_POSITION_UPDATED, + TRACKER_SWITCH_STATUS_UPDATED, TRACKER_WELLNESS_STATUS_UPDATED, ) @@ -225,13 +224,16 @@ class TractiveClient: ): self._last_hw_time = event["hardware"]["time"] self._send_hardware_update(event) - if ( "position" in event and self._last_pos_time != event["position"]["time"] ): self._last_pos_time = event["position"]["time"] self._send_position_update(event) + # If any key belonging to the switch is present in the event, + # we send a switch status update + if bool(set(SWITCH_KEY_MAP.values()).intersection(event)): + self._send_switch_update(event) except aiotractive.exceptions.UnauthorizedError: self._config_entry.async_start_reauth(self._hass) await self.unsubscribe() @@ -266,14 +268,21 @@ class TractiveClient: ATTR_BATTERY_LEVEL: event["hardware"]["battery_level"], ATTR_TRACKER_STATE: event["tracker_state"].lower(), ATTR_BATTERY_CHARGING: event["charging_state"] == "CHARGING", - ATTR_LIVE_TRACKING: event.get("live_tracking", {}).get("active"), - ATTR_BUZZER: event.get("buzzer_control", {}).get("active"), - ATTR_LED: event.get("led_control", {}).get("active"), } self._dispatch_tracker_event( TRACKER_HARDWARE_STATUS_UPDATED, event["tracker_id"], payload ) + def _send_switch_update(self, event: dict[str, Any]) -> None: + # Sometimes the event contains data for all switches, sometimes only for one. + payload = {} + for switch, key in SWITCH_KEY_MAP.items(): + if switch_data := event.get(key): + payload[switch] = switch_data["active"] + self._dispatch_tracker_event( + TRACKER_SWITCH_STATUS_UPDATED, event["tracker_id"], payload + ) + def _send_activity_update(self, event: dict[str, Any]) -> None: payload = { ATTR_MINUTES_ACTIVE: event["progress"]["achieved_minutes"], diff --git a/homeassistant/components/tractive/const.py b/homeassistant/components/tractive/const.py index 254a8c274f3..acb4f6f7487 100644 --- a/homeassistant/components/tractive/const.py +++ b/homeassistant/components/tractive/const.py @@ -26,9 +26,16 @@ CLIENT_ID = "625e5349c3c3b41c28a669f1" CLIENT = "client" TRACKABLES = "trackables" +TRACKER_ACTIVITY_STATUS_UPDATED = f"{DOMAIN}_tracker_activity_updated" TRACKER_HARDWARE_STATUS_UPDATED = f"{DOMAIN}_tracker_hardware_status_updated" TRACKER_POSITION_UPDATED = f"{DOMAIN}_tracker_position_updated" -TRACKER_ACTIVITY_STATUS_UPDATED = f"{DOMAIN}_tracker_activity_updated" +TRACKER_SWITCH_STATUS_UPDATED = f"{DOMAIN}_tracker_switch_updated" TRACKER_WELLNESS_STATUS_UPDATED = f"{DOMAIN}_tracker_wellness_updated" SERVER_UNAVAILABLE = f"{DOMAIN}_server_unavailable" + +SWITCH_KEY_MAP = { + ATTR_LIVE_TRACKING: "live_tracking", + ATTR_BUZZER: "buzzer_control", + ATTR_LED: "led_control", +} diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py index 55acdb9bdcd..58c82bd6514 100644 --- a/homeassistant/components/tractive/switch.py +++ b/homeassistant/components/tractive/switch.py @@ -21,7 +21,7 @@ from .const import ( CLIENT, DOMAIN, TRACKABLES, - TRACKER_HARDWARE_STATUS_UPDATED, + TRACKER_SWITCH_STATUS_UPDATED, ) from .entity import TractiveEntity @@ -99,11 +99,10 @@ class TractiveSwitch(TractiveEntity, SwitchEntity): client, item.trackable, item.tracker_details, - f"{TRACKER_HARDWARE_STATUS_UPDATED}-{item.tracker_details['_id']}", + f"{TRACKER_SWITCH_STATUS_UPDATED}-{item.tracker_details['_id']}", ) self._attr_unique_id = f"{item.trackable['_id']}_{description.key}" - self._attr_available = False self._tracker = item.tracker self._method = getattr(self, description.method) self.entity_description = description @@ -111,9 +110,15 @@ class TractiveSwitch(TractiveEntity, SwitchEntity): @callback def handle_status_update(self, event: dict[str, Any]) -> None: """Handle status update.""" + if self.entity_description.key not in event: + return + + # We received an event, so the service is online and the switch entities should + # be available. + self._attr_available = True self._attr_is_on = event[self.entity_description.key] - super().handle_status_update(event) + self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: """Turn on a switch.""" diff --git a/homeassistant/components/tradfri/fan.py b/homeassistant/components/tradfri/fan.py index c41b24a2647..5c0f05004ba 100644 --- a/homeassistant/components/tradfri/fan.py +++ b/homeassistant/components/tradfri/fan.py @@ -119,8 +119,7 @@ class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity): if not self._device_control: return - if not preset_mode == ATTR_AUTO: - raise ValueError("Preset must be 'Auto'.") + # Preset must be 'Auto' await self._api(self._device_control.turn_on_auto_mode()) diff --git a/homeassistant/components/tradfri/strings.json b/homeassistant/components/tradfri/strings.json index 0a9a86bd23a..69a28a567ab 100644 --- a/homeassistant/components/tradfri/strings.json +++ b/homeassistant/components/tradfri/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "security_code": "Security Code" + }, + "data_description": { + "host": "Hostname or IP address of your Trådfri gateway." } } }, diff --git a/homeassistant/components/trafikverket_camera/__init__.py b/homeassistant/components/trafikverket_camera/__init__.py index d9d28cfe13b..3ac3ce35882 100644 --- a/homeassistant/components/trafikverket_camera/__init__.py +++ b/homeassistant/components/trafikverket_camera/__init__.py @@ -6,7 +6,7 @@ import logging from pytrafikverket.trafikverket_camera import TrafikverketCamera from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -42,13 +42,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate old entry.""" + api_key = entry.data[CONF_API_KEY] + web_session = async_get_clientsession(hass) + camera_api = TrafikverketCamera(web_session, api_key) # Change entry unique id from location to camera id if entry.version == 1: location = entry.data[CONF_LOCATION] - api_key = entry.data[CONF_API_KEY] - - web_session = async_get_clientsession(hass) - camera_api = TrafikverketCamera(web_session, api_key) try: camera_info = await camera_api.async_get_camera(location) @@ -60,14 +59,40 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if camera_id := camera_info.camera_id: entry.version = 2 - _LOGGER.debug( - "Migrate Trafikverket Camera config entry unique id to %s", - camera_id, - ) hass.config_entries.async_update_entry( entry, unique_id=f"{DOMAIN}-{camera_id}", ) + _LOGGER.debug( + "Migrated Trafikverket Camera config entry unique id to %s", + camera_id, + ) + else: + _LOGGER.error("Could not migrate the config entry. Camera has no id") + return False + + # Change entry data from location to id + if entry.version == 2: + location = entry.data[CONF_LOCATION] + + try: + camera_info = await camera_api.async_get_camera(location) + except Exception: # pylint: disable=broad-except + _LOGGER.error( + "Could not migrate the config entry. No connection to the api" + ) + return False + + if camera_id := camera_info.camera_id: + entry.version = 3 + _LOGGER.debug( + "Migrate Trafikverket Camera config entry unique id to %s", + camera_id, + ) + new_data = entry.data.copy() + new_data.pop(CONF_LOCATION) + new_data[CONF_ID] = camera_id + hass.config_entries.async_update_entry(entry, data=new_data) return True _LOGGER.error("Could not migrate the config entry. Camera has no id") return False diff --git a/homeassistant/components/trafikverket_camera/config_flow.py b/homeassistant/components/trafikverket_camera/config_flow.py index e75bc0bfa30..7572855b7d4 100644 --- a/homeassistant/components/trafikverket_camera/config_flow.py +++ b/homeassistant/components/trafikverket_camera/config_flow.py @@ -14,7 +14,7 @@ from pytrafikverket.trafikverket_camera import CameraInfo, TrafikverketCamera import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_ID from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -25,7 +25,7 @@ from .const import CONF_LOCATION, DOMAIN class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Trafikverket Camera integration.""" - VERSION = 2 + VERSION = 3 entry: config_entries.ConfigEntry | None @@ -53,10 +53,7 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if camera_info: camera_id = camera_info.camera_id - if _location := camera_info.location: - camera_location = _location - else: - camera_location = camera_info.camera_name + camera_location = camera_info.camera_name or "Trafikverket Camera" return (errors, camera_location, camera_id) @@ -76,9 +73,7 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): api_key = user_input[CONF_API_KEY] assert self.entry is not None - errors, _, _ = await self.validate_input( - api_key, self.entry.data[CONF_LOCATION] - ) + errors, _, _ = await self.validate_input(api_key, self.entry.data[CONF_ID]) if not errors: self.hass.config_entries.async_update_entry( @@ -121,10 +116,7 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return self.async_create_entry( title=camera_location, - data={ - CONF_API_KEY: api_key, - CONF_LOCATION: camera_location, - }, + data={CONF_API_KEY: api_key, CONF_ID: camera_id}, ) return self.async_show_form( diff --git a/homeassistant/components/trafikverket_camera/coordinator.py b/homeassistant/components/trafikverket_camera/coordinator.py index eb5a047ca73..8270fecd487 100644 --- a/homeassistant/components/trafikverket_camera/coordinator.py +++ b/homeassistant/components/trafikverket_camera/coordinator.py @@ -15,13 +15,13 @@ from pytrafikverket.exceptions import ( from pytrafikverket.trafikverket_camera import CameraInfo, TrafikverketCamera from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_LOCATION, DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) TIME_BETWEEN_UPDATES = timedelta(minutes=5) @@ -48,14 +48,14 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[CameraData]): ) self.session = async_get_clientsession(hass) self._camera_api = TrafikverketCamera(self.session, entry.data[CONF_API_KEY]) - self._location = entry.data[CONF_LOCATION] + self._id = entry.data[CONF_ID] async def _async_update_data(self) -> CameraData: """Fetch data from Trafikverket.""" camera_data: CameraInfo image: bytes | None = None try: - camera_data = await self._camera_api.async_get_camera(self._location) + camera_data = await self._camera_api.async_get_camera(self._id) except (NoCameraFound, MultipleCamerasFound, UnknownError) as error: raise UpdateFailed from error except InvalidAuthentication as error: diff --git a/homeassistant/components/trafikverket_camera/manifest.json b/homeassistant/components/trafikverket_camera/manifest.json index 7b457063c6c..31eb911e24d 100644 --- a/homeassistant/components/trafikverket_camera/manifest.json +++ b/homeassistant/components/trafikverket_camera/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_camera", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.7"] + "requirements": ["pytrafikverket==0.3.9.1"] } diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json index 7d0171bc8bb..7f750c26c57 100644 --- a/homeassistant/components/trafikverket_ferry/manifest.json +++ b/homeassistant/components/trafikverket_ferry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_ferry", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.7"] + "requirements": ["pytrafikverket==0.3.9.1"] } diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index b7808dc38b2..df05942add1 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -9,7 +9,6 @@ from typing import Any from pytrafikverket import TrafikverketTrain from pytrafikverket.exceptions import ( InvalidAuthentication, - MultipleTrainAnnouncementFound, MultipleTrainStationsFound, NoTrainAnnouncementFound, NoTrainStationFound, @@ -107,8 +106,6 @@ async def validate_input( errors["base"] = "more_stations" except NoTrainAnnouncementFound: errors["base"] = "no_trains" - except MultipleTrainAnnouncementFound: - errors["base"] = "multiple_trains" except UnknownError as error: _LOGGER.error("Unknown error occurred during validation %s", str(error)) errors["base"] = "cannot_connect" diff --git a/homeassistant/components/trafikverket_train/coordinator.py b/homeassistant/components/trafikverket_train/coordinator.py index ea852ab7fdf..91a7e9f07b2 100644 --- a/homeassistant/components/trafikverket_train/coordinator.py +++ b/homeassistant/components/trafikverket_train/coordinator.py @@ -8,7 +8,6 @@ import logging from pytrafikverket import TrafikverketTrain from pytrafikverket.exceptions import ( InvalidAuthentication, - MultipleTrainAnnouncementFound, NoTrainAnnouncementFound, UnknownError, ) @@ -112,7 +111,6 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): raise ConfigEntryAuthFailed from error except ( NoTrainAnnouncementFound, - MultipleTrainAnnouncementFound, UnknownError, ) as error: raise UpdateFailed( diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index b1dd39c5156..b68a56b3793 100644 --- a/homeassistant/components/trafikverket_train/manifest.json +++ b/homeassistant/components/trafikverket_train/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_train", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.7"] + "requirements": ["pytrafikverket==0.3.9.1"] } diff --git a/homeassistant/components/trafikverket_train/strings.json b/homeassistant/components/trafikverket_train/strings.json index 78d69c880ae..a2c286867b2 100644 --- a/homeassistant/components/trafikverket_train/strings.json +++ b/homeassistant/components/trafikverket_train/strings.json @@ -10,7 +10,6 @@ "invalid_station": "Could not find a station with the specified name", "more_stations": "Found multiple stations with the specified name", "no_trains": "No train found", - "multiple_trains": "Multiple trains found", "incorrect_api_key": "Invalid API key for selected account" }, "step": { diff --git a/homeassistant/components/trafikverket_weatherstation/config_flow.py b/homeassistant/components/trafikverket_weatherstation/config_flow.py index f8f86298045..89cbd373665 100644 --- a/homeassistant/components/trafikverket_weatherstation/config_flow.py +++ b/homeassistant/components/trafikverket_weatherstation/config_flow.py @@ -1,6 +1,9 @@ """Adds config flow for Trafikverket Weather integration.""" from __future__ import annotations +from collections.abc import Mapping +from typing import Any + from pytrafikverket.exceptions import ( InvalidAuthentication, MultipleWeatherStationsFound, @@ -23,7 +26,7 @@ class TVWeatherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - entry: config_entries.ConfigEntry + entry: config_entries.ConfigEntry | None = None async def validate_input(self, sensor_api: str, station: str) -> None: """Validate input from user input.""" @@ -71,3 +74,47 @@ class TVWeatherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle re-authentication with Trafikverket.""" + + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm re-authentication with Trafikverket.""" + errors: dict[str, str] = {} + + if user_input: + api_key = user_input[CONF_API_KEY] + + assert self.entry is not None + + try: + await self.validate_input(api_key, self.entry.data[CONF_STATION]) + except InvalidAuthentication: + errors["base"] = "invalid_auth" + except NoWeatherStationFound: + errors["base"] = "invalid_station" + except MultipleWeatherStationsFound: + errors["base"] = "more_stations" + except Exception: # pylint: disable=broad-exception-caught + errors["base"] = "cannot_connect" + else: + self.hass.config_entries.async_update_entry( + self.entry, + data={ + **self.entry.data, + CONF_API_KEY: api_key, + }, + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): cv.string}), + errors=errors, + ) diff --git a/homeassistant/components/trafikverket_weatherstation/const.py b/homeassistant/components/trafikverket_weatherstation/const.py index 0d4680e9b37..34c18359ee4 100644 --- a/homeassistant/components/trafikverket_weatherstation/const.py +++ b/homeassistant/components/trafikverket_weatherstation/const.py @@ -5,13 +5,3 @@ DOMAIN = "trafikverket_weatherstation" CONF_STATION = "station" PLATFORMS = [Platform.SENSOR] ATTRIBUTION = "Data provided by Trafikverket" - -NONE_IS_ZERO_SENSORS = { - "air_temp", - "road_temp", - "wind_direction", - "wind_speed", - "wind_speed_max", - "humidity", - "precipitation_amount", -} diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index d9b4f20eeb7..bd4b2b99b6a 100644 --- a/homeassistant/components/trafikverket_weatherstation/manifest.json +++ b/homeassistant/components/trafikverket_weatherstation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_weatherstation", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.7"] + "requirements": ["pytrafikverket==0.3.9.1"] } diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index 3ec7d137b6e..607a230fbbe 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -1,9 +1,11 @@ """Weather information for air and road temperature (by Trafikverket).""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from datetime import datetime -from typing import TYPE_CHECKING + +from pytrafikverket.trafikverket_weather import WeatherStationInfo from homeassistant.components.sensor import ( SensorDeviceClass, @@ -15,6 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEGREE, PERCENTAGE, + UnitOfLength, UnitOfSpeed, UnitOfTemperature, UnitOfVolumetricFlux, @@ -24,48 +27,18 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util.dt import as_utc +from homeassistant.util import dt as dt_util -from .const import ATTRIBUTION, CONF_STATION, DOMAIN, NONE_IS_ZERO_SENSORS +from .const import ATTRIBUTION, CONF_STATION, DOMAIN from .coordinator import TVDataUpdateCoordinator -WIND_DIRECTIONS = [ - "east", - "north_east", - "east_south_east", - "north", - "north_north_east", - "north_north_west", - "north_west", - "south", - "south_east", - "south_south_west", - "south_west", - "west", -] -PRECIPITATION_AMOUNTNAME = [ - "error", - "mild_rain", - "moderate_rain", - "heavy_rain", - "mild_snow_rain", - "moderate_snow_rain", - "heavy_snow_rain", - "mild_snow", - "moderate_snow", - "heavy_snow", - "other", - "none", - "error", -] PRECIPITATION_TYPE = [ - "drizzle", - "hail", - "none", + "no", "rain", - "snow", - "rain_snow_mixed", "freezing_rain", + "snow", + "sleet", + "yes", ] @@ -73,7 +46,7 @@ PRECIPITATION_TYPE = [ class TrafikverketRequiredKeysMixin: """Mixin for required keys.""" - api_key: str + value_fn: Callable[[WeatherStationInfo], StateType | datetime] @dataclass @@ -83,11 +56,18 @@ class TrafikverketSensorEntityDescription( """Describes Trafikverket sensor entity.""" +def add_utc_timezone(date_time: datetime | None) -> datetime | None: + """Add UTC timezone if datetime.""" + if date_time: + return date_time.replace(tzinfo=dt_util.UTC) + return None + + SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( TrafikverketSensorEntityDescription( key="air_temp", translation_key="air_temperature", - api_key="air_temp", + value_fn=lambda data: data.air_temp or 0, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -95,7 +75,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( TrafikverketSensorEntityDescription( key="road_temp", translation_key="road_temperature", - api_key="road_temp", + value_fn=lambda data: data.road_temp or 0, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -103,8 +83,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( TrafikverketSensorEntityDescription( key="precipitation", translation_key="precipitation", - api_key="precipitationtype_translated", - name="Precipitation type", + value_fn=lambda data: data.precipitationtype, icon="mdi:weather-snowy-rainy", entity_registry_enabled_default=False, options=PRECIPITATION_TYPE, @@ -113,24 +92,14 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( TrafikverketSensorEntityDescription( key="wind_direction", translation_key="wind_direction", - api_key="winddirection", - name="Wind direction", + value_fn=lambda data: data.winddirection, native_unit_of_measurement=DEGREE, icon="mdi:flag-triangle", state_class=SensorStateClass.MEASUREMENT, ), - TrafikverketSensorEntityDescription( - key="wind_direction_text", - translation_key="wind_direction_text", - api_key="winddirectiontext_translated", - name="Wind direction text", - icon="mdi:flag-triangle", - options=WIND_DIRECTIONS, - device_class=SensorDeviceClass.ENUM, - ), TrafikverketSensorEntityDescription( key="wind_speed", - api_key="windforce", + value_fn=lambda data: data.windforce or 0, native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, @@ -138,7 +107,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( TrafikverketSensorEntityDescription( key="wind_speed_max", translation_key="wind_speed_max", - api_key="windforcemax", + value_fn=lambda data: data.windforcemax or 0, native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, device_class=SensorDeviceClass.WIND_SPEED, icon="mdi:weather-windy-variant", @@ -147,7 +116,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( ), TrafikverketSensorEntityDescription( key="humidity", - api_key="humidity", + value_fn=lambda data: data.humidity or 0, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, entity_registry_enabled_default=False, @@ -155,24 +124,85 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( ), TrafikverketSensorEntityDescription( key="precipitation_amount", - api_key="precipitation_amount", + value_fn=lambda data: data.precipitation_amount or 0, native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, state_class=SensorStateClass.MEASUREMENT, ), - TrafikverketSensorEntityDescription( - key="precipitation_amountname", - translation_key="precipitation_amountname", - api_key="precipitation_amountname_translated", - icon="mdi:weather-pouring", - entity_registry_enabled_default=False, - options=PRECIPITATION_AMOUNTNAME, - device_class=SensorDeviceClass.ENUM, - ), TrafikverketSensorEntityDescription( key="measure_time", translation_key="measure_time", - api_key="measure_time", + value_fn=lambda data: data.measure_time, + icon="mdi:clock", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.TIMESTAMP, + ), + TrafikverketSensorEntityDescription( + key="dew_point", + translation_key="dew_point", + value_fn=lambda data: data.dew_point or 0, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TrafikverketSensorEntityDescription( + key="visible_distance", + translation_key="visible_distance", + value_fn=lambda data: data.visible_distance, + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TrafikverketSensorEntityDescription( + key="road_ice_depth", + translation_key="road_ice_depth", + value_fn=lambda data: data.road_ice_depth, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TrafikverketSensorEntityDescription( + key="road_snow_depth", + translation_key="road_snow_depth", + value_fn=lambda data: data.road_snow_depth, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TrafikverketSensorEntityDescription( + key="road_water_depth", + translation_key="road_water_depth", + value_fn=lambda data: data.road_water_depth, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TrafikverketSensorEntityDescription( + key="road_water_equivalent_depth", + translation_key="road_water_equivalent_depth", + value_fn=lambda data: data.road_water_equivalent_depth, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TrafikverketSensorEntityDescription( + key="wind_height", + translation_key="wind_height", + value_fn=lambda data: data.wind_height, + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TrafikverketSensorEntityDescription( + key="modified_time", + translation_key="modified_time", + value_fn=lambda data: add_utc_timezone(data.modified_time), icon="mdi:clock", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, @@ -195,12 +225,6 @@ async def async_setup_entry( ) -def _to_datetime(measuretime: str) -> datetime: - """Return isoformatted utc time.""" - time_obj = datetime.strptime(measuretime, "%Y-%m-%dT%H:%M:%S.%f%z") - return as_utc(time_obj) - - class TrafikverketWeatherStation( CoordinatorEntity[TVDataUpdateCoordinator], SensorEntity ): @@ -233,23 +257,4 @@ class TrafikverketWeatherStation( @property def native_value(self) -> StateType | datetime: """Return state of sensor.""" - if self.entity_description.api_key == "measure_time": - if TYPE_CHECKING: - assert self.coordinator.data.measure_time - return self.coordinator.data.measure_time - - state: StateType = getattr( - self.coordinator.data, self.entity_description.api_key - ) - - # For zero value state the api reports back None for certain sensors. - if state is None and self.entity_description.key in NONE_IS_ZERO_SENSORS: - return 0 - return state - - @property - def available(self) -> bool: - """Return if entity is available.""" - if TYPE_CHECKING: - assert self.coordinator.data.active - return self.coordinator.data.active and super().available + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/trafikverket_weatherstation/strings.json b/homeassistant/components/trafikverket_weatherstation/strings.json index 9ff1b077f33..a4838dab0e2 100644 --- a/homeassistant/components/trafikverket_weatherstation/strings.json +++ b/homeassistant/components/trafikverket_weatherstation/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -15,6 +16,11 @@ "api_key": "[%key:common::config_flow::data::api_key%]", "station": "Station" } + }, + "reauth_confirm": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } } } }, @@ -29,58 +35,46 @@ "precipitation": { "name": "Precipitation type", "state": { - "drizzle": "Drizzle", - "hail": "Hail", - "none": "None", + "no": "None", "rain": "Rain", + "freezing_rain": "Freezing rain", "snow": "Snow", - "rain_snow_mixed": "Rain and snow mixed", - "freezing_rain": "Freezing rain" + "sleet": "Sleet", + "yes": "Yes (unknown)" } }, "wind_direction": { "name": "Wind direction" }, - "wind_direction_text": { - "name": "Wind direction text", - "state": { - "east": "East", - "north_east": "North east", - "east_south_east": "East-south east", - "north": "North", - "north_north_east": "North-north east", - "north_north_west": "North-north west", - "north_west": "North west", - "south": "South", - "south_east": "South east", - "south_south_west": "South-south west", - "south_west": "South west", - "west": "West" - } - }, "wind_speed_max": { "name": "Wind speed max" }, - "precipitation_amountname": { - "name": "Precipitation name", - "state": { - "error": "Error", - "mild_rain": "Mild rain", - "moderate_rain": "Moderate rain", - "heavy_rain": "Heavy rain", - "mild_snow_rain": "Mild rain and snow mixed", - "moderate_snow_rain": "Moderate rain and snow mixed", - "heavy_snow_rain": "Heavy rain and snow mixed", - "mild_snow": "Mild snow", - "moderate_snow": "Moderate snow", - "heavy_snow": "Heavy snow", - "other": "Other", - "none": "None", - "unknown": "Unknown" - } - }, "measure_time": { "name": "Measure time" + }, + "dew_point": { + "name": "Dew point" + }, + "visible_distance": { + "name": "Visible distance" + }, + "road_ice_depth": { + "name": "Ice depth on road" + }, + "road_snow_depth": { + "name": "Snow depth on road" + }, + "road_water_depth": { + "name": "Water depth on road" + }, + "road_water_equivalent_depth": { + "name": "Water equivalent depth on road" + }, + "wind_height": { + "name": "Wind measurement height" + }, + "modified_time": { + "name": "Data modified time" } } } diff --git a/homeassistant/components/transmission/const.py b/homeassistant/components/transmission/const.py index 77d2baf7213..64b15c51691 100644 --- a/homeassistant/components/transmission/const.py +++ b/homeassistant/components/transmission/const.py @@ -7,8 +7,6 @@ from transmission_rpc import Torrent DOMAIN = "transmission" -SWITCH_TYPES = {"on_off": "Switch", "turtle_mode": "Turtle mode"} - ORDER_NEWEST_FIRST = "newest_first" ORDER_OLDEST_FIRST = "oldest_first" ORDER_BEST_RATIO_FIRST = "best_ratio_first" @@ -16,9 +14,9 @@ ORDER_WORST_RATIO_FIRST = "worst_ratio_first" SUPPORTED_ORDER_MODES: dict[str, Callable[[list[Torrent]], list[Torrent]]] = { ORDER_NEWEST_FIRST: lambda torrents: sorted( - torrents, key=lambda t: t.date_added, reverse=True + torrents, key=lambda t: t.added_date, reverse=True ), - ORDER_OLDEST_FIRST: lambda torrents: sorted(torrents, key=lambda t: t.date_added), + ORDER_OLDEST_FIRST: lambda torrents: sorted(torrents, key=lambda t: t.added_date), ORDER_WORST_RATIO_FIRST: lambda torrents: sorted(torrents, key=lambda t: t.ratio), ORDER_BEST_RATIO_FIRST: lambda torrents: sorted( torrents, key=lambda t: t.ratio, reverse=True diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py index 91597d0e43d..d03ef5e37fb 100644 --- a/homeassistant/components/transmission/coordinator.py +++ b/homeassistant/components/transmission/coordinator.py @@ -146,7 +146,7 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): """Stop all active torrents.""" if not self.torrents: return - torrent_ids = [torrent.id for torrent in self.torrents] + torrent_ids: list[int | str] = [torrent.id for torrent in self.torrents] self.api.stop_torrent(torrent_ids) def set_alt_speed_enabled(self, is_enabled: bool) -> None: @@ -158,4 +158,4 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): if self._session is None: return None - return self._session.alt_speed_enabled # type: ignore[no-any-return] + return self._session.alt_speed_enabled diff --git a/homeassistant/components/transmission/manifest.json b/homeassistant/components/transmission/manifest.json index 17b3bbbf49b..ad89ae94033 100644 --- a/homeassistant/components/transmission/manifest.json +++ b/homeassistant/components/transmission/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/transmission", "iot_class": "local_polling", "loggers": ["transmissionrpc"], - "requirements": ["transmission-rpc==4.1.5"] + "requirements": ["transmission-rpc==7.0.3"] } diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index c3ba418f885..20f4fc95c87 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -6,7 +6,11 @@ from typing import Any from transmission_rpc.torrent import Torrent -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_IDLE, UnitOfDataRate from homeassistant.core import HomeAssistant @@ -24,6 +28,25 @@ from .const import ( ) from .coordinator import TransmissionDataUpdateCoordinator +SPEED_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription(key="download", translation_key="download_speed"), + SensorEntityDescription(key="upload", translation_key="upload_speed"), +) + +STATUS_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription(key="status", translation_key="transmission_status"), +) + +TORRENT_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription(key="active_torrents", translation_key="active_torrents"), + SensorEntityDescription(key="paused_torrents", translation_key="paused_torrents"), + SensorEntityDescription(key="total_torrents", translation_key="total_torrents"), + SensorEntityDescription( + key="completed_torrents", translation_key="completed_torrents" + ), + SensorEntityDescription(key="started_torrents", translation_key="started_torrents"), +) + async def async_setup_entry( hass: HomeAssistant, @@ -36,47 +59,19 @@ async def async_setup_entry( config_entry.entry_id ] + entities: list[TransmissionSensor] = [] + entities = [ - TransmissionSpeedSensor( - coordinator, - "download_speed", - "download", - ), - TransmissionSpeedSensor( - coordinator, - "upload_speed", - "upload", - ), - TransmissionStatusSensor( - coordinator, - "transmission_status", - "status", - ), - TransmissionTorrentsSensor( - coordinator, - "active_torrents", - "active_torrents", - ), - TransmissionTorrentsSensor( - coordinator, - "paused_torrents", - "paused_torrents", - ), - TransmissionTorrentsSensor( - coordinator, - "total_torrents", - "total_torrents", - ), - TransmissionTorrentsSensor( - coordinator, - "completed_torrents", - "completed_torrents", - ), - TransmissionTorrentsSensor( - coordinator, - "started_torrents", - "started_torrents", - ), + TransmissionSpeedSensor(coordinator, description) + for description in SPEED_SENSORS + ] + entities += [ + TransmissionStatusSensor(coordinator, description) + for description in STATUS_SENSORS + ] + entities += [ + TransmissionTorrentsSensor(coordinator, description) + for description in TORRENT_SENSORS ] async_add_entities(entities) @@ -88,19 +83,18 @@ class TransmissionSensor( """A base class for all Transmission sensors.""" _attr_has_entity_name = True - _attr_should_poll = False def __init__( self, coordinator: TransmissionDataUpdateCoordinator, - sensor_translation_key: str, - key: str, + entity_description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._attr_translation_key = sensor_translation_key - self._key = key - self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{key}" + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}-{entity_description.key}" + ) self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, @@ -122,7 +116,7 @@ class TransmissionSpeedSensor(TransmissionSensor): data = self.coordinator.data return ( float(data.download_speed) - if self._key == "download" + if self.entity_description.key == "download" else float(data.upload_speed) ) @@ -173,7 +167,7 @@ class TransmissionTorrentsSensor(TransmissionSensor): torrents=self.coordinator.torrents, order=self.coordinator.order, limit=self.coordinator.limit, - statuses=self.MODES[self._key], + statuses=self.MODES[self.entity_description.key], ) return { STATE_ATTR_TORRENT_INFO: info, @@ -183,7 +177,7 @@ class TransmissionTorrentsSensor(TransmissionSensor): def native_value(self) -> int: """Return the count of the sensor.""" torrents = _filter_torrents( - self.coordinator.torrents, statuses=self.MODES[self._key] + self.coordinator.torrents, statuses=self.MODES[self.entity_description.key] ) return len(torrents) @@ -206,7 +200,7 @@ def _torrents_info( torrents = SUPPORTED_ORDER_MODES[order](torrents) for torrent in torrents[:limit]: info = infos[torrent.name] = { - "added_date": torrent.date_added, + "added_date": torrent.added_date, "percent_done": f"{torrent.percent_done * 100:.2f}", "status": torrent.status, "id": torrent.id, diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index 77ffd6a8b2a..8a73eb90829 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -69,6 +69,14 @@ "started_torrents": { "name": "Started torrents" } + }, + "switch": { + "on_off": { + "name": "Switch" + }, + "turtle_mode": { + "name": "Turtle mode" + } } }, "services": { diff --git a/homeassistant/components/transmission/switch.py b/homeassistant/components/transmission/switch.py index 6d236964987..fecda94fbf8 100644 --- a/homeassistant/components/transmission/switch.py +++ b/homeassistant/components/transmission/switch.py @@ -1,21 +1,56 @@ """Support for setting the Transmission BitTorrent client Turtle Mode.""" from collections.abc import Callable +from dataclasses import dataclass import logging from typing import Any -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription 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 homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, SWITCH_TYPES +from .const import DOMAIN from .coordinator import TransmissionDataUpdateCoordinator _LOGGING = logging.getLogger(__name__) +@dataclass +class TransmissionSwitchEntityDescriptionMixin: + """Mixin for required keys.""" + + is_on_func: Callable[[TransmissionDataUpdateCoordinator], bool | None] + on_func: Callable[[TransmissionDataUpdateCoordinator], None] + off_func: Callable[[TransmissionDataUpdateCoordinator], None] + + +@dataclass +class TransmissionSwitchEntityDescription( + SwitchEntityDescription, TransmissionSwitchEntityDescriptionMixin +): + """Entity description class for Transmission switches.""" + + +SWITCH_TYPES: tuple[TransmissionSwitchEntityDescription, ...] = ( + TransmissionSwitchEntityDescription( + key="on_off", + translation_key="on_off", + is_on_func=lambda coordinator: coordinator.data.active_torrent_count > 0, + on_func=lambda coordinator: coordinator.start_torrents(), + off_func=lambda coordinator: coordinator.stop_torrents(), + ), + TransmissionSwitchEntityDescription( + key="turtle_mode", + translation_key="turtle_mode", + is_on_func=lambda coordinator: coordinator.get_alt_speed_enabled(), + on_func=lambda coordinator: coordinator.set_alt_speed_enabled(True), + off_func=lambda coordinator: coordinator.set_alt_speed_enabled(False), + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -27,11 +62,9 @@ async def async_setup_entry( config_entry.entry_id ] - entities = [] - for switch_type, switch_name in SWITCH_TYPES.items(): - entities.append(TransmissionSwitch(switch_type, switch_name, coordinator)) - - async_add_entities(entities) + async_add_entities( + TransmissionSwitch(coordinator, description) for description in SWITCH_TYPES + ) class TransmissionSwitch( @@ -39,21 +72,20 @@ class TransmissionSwitch( ): """Representation of a Transmission switch.""" + entity_description: TransmissionSwitchEntityDescription _attr_has_entity_name = True - _attr_should_poll = False def __init__( self, - switch_type: str, - switch_name: str, coordinator: TransmissionDataUpdateCoordinator, + entity_description: TransmissionSwitchEntityDescription, ) -> None: """Initialize the Transmission switch.""" super().__init__(coordinator) - self._attr_name = switch_name - self.type = switch_type - self.unsub_update: Callable[[], None] | None = None - self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{switch_type}" + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}-{entity_description.key}" + ) self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, @@ -63,34 +95,18 @@ class TransmissionSwitch( @property def is_on(self) -> bool: """Return true if device is on.""" - active = None - if self.type == "on_off": - active = self.coordinator.data.active_torrent_count > 0 - elif self.type == "turtle_mode": - active = self.coordinator.get_alt_speed_enabled() - - return bool(active) + return bool(self.entity_description.is_on_func(self.coordinator)) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - if self.type == "on_off": - _LOGGING.debug("Starting all torrents") - await self.hass.async_add_executor_job(self.coordinator.start_torrents) - elif self.type == "turtle_mode": - _LOGGING.debug("Turning Turtle Mode of Transmission on") - await self.hass.async_add_executor_job( - self.coordinator.set_alt_speed_enabled, True - ) + await self.hass.async_add_executor_job( + self.entity_description.on_func, self.coordinator + ) await self.coordinator.async_request_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - if self.type == "on_off": - _LOGGING.debug("Stopping all torrents") - await self.hass.async_add_executor_job(self.coordinator.stop_torrents) - if self.type == "turtle_mode": - _LOGGING.debug("Turning Turtle Mode of Transmission off") - await self.hass.async_add_executor_job( - self.coordinator.set_alt_speed_enabled, False - ) + await self.hass.async_add_executor_job( + self.entity_description.off_func, self.coordinator + ) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 4402722e37f..38715825875 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -13,6 +13,8 @@ import logging import mimetypes import os import re +import subprocess +import tempfile from typing import Any, TypedDict, final from aiohttp import web @@ -20,7 +22,7 @@ import mutagen from mutagen.id3 import ID3, TextFrame as ID3Text import voluptuous as vol -from homeassistant.components import websocket_api +from homeassistant.components import ffmpeg, websocket_api from homeassistant.components.http import HomeAssistantView from homeassistant.components.media_player import ( ATTR_MEDIA_ANNOUNCE, @@ -72,11 +74,15 @@ __all__ = [ "async_get_media_source_audio", "async_support_options", "ATTR_AUDIO_OUTPUT", + "ATTR_PREFERRED_FORMAT", + "ATTR_PREFERRED_SAMPLE_RATE", + "ATTR_PREFERRED_SAMPLE_CHANNELS", "CONF_LANG", "DEFAULT_CACHE_DIR", "generate_media_source_id", "PLATFORM_SCHEMA_BASE", "PLATFORM_SCHEMA", + "SampleFormat", "Provider", "TtsAudioType", "Voice", @@ -86,6 +92,9 @@ _LOGGER = logging.getLogger(__name__) ATTR_PLATFORM = "platform" ATTR_AUDIO_OUTPUT = "audio_output" +ATTR_PREFERRED_FORMAT = "preferred_format" +ATTR_PREFERRED_SAMPLE_RATE = "preferred_sample_rate" +ATTR_PREFERRED_SAMPLE_CHANNELS = "preferred_sample_channels" ATTR_MEDIA_PLAYER_ENTITY_ID = "media_player_entity_id" ATTR_VOICE = "voice" @@ -199,6 +208,84 @@ def async_get_text_to_speech_languages(hass: HomeAssistant) -> set[str]: return languages +async def async_convert_audio( + hass: HomeAssistant, + from_extension: str, + audio_bytes: bytes, + to_extension: str, + to_sample_rate: int | None = None, + to_sample_channels: int | None = None, +) -> bytes: + """Convert audio to a preferred format using ffmpeg.""" + ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) + return await hass.async_add_executor_job( + lambda: _convert_audio( + ffmpeg_manager.binary, + from_extension, + audio_bytes, + to_extension, + to_sample_rate=to_sample_rate, + to_sample_channels=to_sample_channels, + ) + ) + + +def _convert_audio( + ffmpeg_binary: str, + from_extension: str, + audio_bytes: bytes, + to_extension: str, + to_sample_rate: int | None = None, + to_sample_channels: int | None = None, +) -> bytes: + """Convert audio to a preferred format using ffmpeg.""" + + # We have to use a temporary file here because some formats like WAV store + # the length of the file in the header, and therefore cannot be written in a + # streaming fashion. + with tempfile.NamedTemporaryFile( + mode="wb+", suffix=f".{to_extension}" + ) as output_file: + # input + command = [ + ffmpeg_binary, + "-y", # overwrite temp file + "-f", + from_extension, + "-i", + "pipe:", # input from stdin + ] + + # output + command.extend(["-f", to_extension]) + + if to_sample_rate is not None: + command.extend(["-ar", str(to_sample_rate)]) + + if to_sample_channels is not None: + command.extend(["-ac", str(to_sample_channels)]) + + if to_extension == "mp3": + # Max quality for MP3 + command.extend(["-q:a", "0"]) + + command.append(output_file.name) + + with subprocess.Popen( + command, stdin=subprocess.PIPE, stderr=subprocess.PIPE + ) as proc: + _stdout, stderr = proc.communicate(input=audio_bytes) + if proc.returncode != 0: + _LOGGER.error(stderr.decode()) + raise RuntimeError( + f"Unexpected error while running ffmpeg with arguments: {command}." + "See log for details." + ) + + output_file.seek(0) + return output_file.read() + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up TTS.""" websocket_api.async_register_command(hass, websocket_list_engines) @@ -482,7 +569,18 @@ class SpeechManager: merged_options = dict(engine_instance.default_options or {}) merged_options.update(options or {}) - supported_options = engine_instance.supported_options or [] + supported_options = list(engine_instance.supported_options or []) + + # ATTR_PREFERRED_* options are always "supported" since they're used to + # convert audio after the TTS has run (if necessary). + supported_options.extend( + ( + ATTR_PREFERRED_FORMAT, + ATTR_PREFERRED_SAMPLE_RATE, + ATTR_PREFERRED_SAMPLE_CHANNELS, + ) + ) + invalid_opts = [ opt_name for opt_name in merged_options if opt_name not in supported_options ] @@ -520,12 +618,7 @@ class SpeechManager: # Load speech from engine into memory else: filename = await self._async_get_tts_audio( - engine_instance, - cache_key, - message, - use_cache, - language, - options, + engine_instance, cache_key, message, use_cache, language, options ) return f"/api/tts_proxy/{filename}" @@ -590,10 +683,10 @@ class SpeechManager: This method is a coroutine. """ - if options is not None and ATTR_AUDIO_OUTPUT in options: - expected_extension = options[ATTR_AUDIO_OUTPUT] - else: - expected_extension = None + options = options or {} + + # Default to MP3 unless a different format is preferred + final_extension = options.get(ATTR_PREFERRED_FORMAT, "mp3") async def get_tts_data() -> str: """Handle data available.""" @@ -614,8 +707,27 @@ class SpeechManager: f"No TTS from {engine_instance.name} for '{message}'" ) + # Only convert if we have a preferred format different than the + # expected format from the TTS system, or if a specific sample + # rate/format/channel count is requested. + needs_conversion = ( + (final_extension != extension) + or (ATTR_PREFERRED_SAMPLE_RATE in options) + or (ATTR_PREFERRED_SAMPLE_CHANNELS in options) + ) + + if needs_conversion: + data = await async_convert_audio( + self.hass, + extension, + data, + to_extension=final_extension, + to_sample_rate=options.get(ATTR_PREFERRED_SAMPLE_RATE), + to_sample_channels=options.get(ATTR_PREFERRED_SAMPLE_CHANNELS), + ) + # Create file infos - filename = f"{cache_key}.{extension}".lower() + filename = f"{cache_key}.{final_extension}".lower() # Validate filename if not _RE_VOICE_FILE.match(filename) and not _RE_LEGACY_VOICE_FILE.match( @@ -626,10 +738,11 @@ class SpeechManager: ) # Save to memory - if extension == "mp3": + if final_extension == "mp3": data = self.write_tags( filename, data, engine_instance.name, message, language, options ) + self._async_store_to_memcache(cache_key, filename, data) if cache: @@ -641,9 +754,6 @@ class SpeechManager: audio_task = self.hass.async_create_task(get_tts_data()) - if expected_extension is None: - return await audio_task - def handle_error(_future: asyncio.Future) -> None: """Handle error.""" if audio_task.exception(): @@ -651,7 +761,7 @@ class SpeechManager: audio_task.add_done_callback(handle_error) - filename = f"{cache_key}.{expected_extension}".lower() + filename = f"{cache_key}.{final_extension}".lower() self.mem_cache[cache_key] = { "filename": filename, "voice": b"", @@ -747,11 +857,12 @@ class SpeechManager: raise HomeAssistantError(f"{cache_key} not in cache!") await self._async_file_to_mem(cache_key) - content, _ = mimetypes.guess_type(filename) cached = self.mem_cache[cache_key] if pending := cached.get("pending"): await pending cached = self.mem_cache[cache_key] + + content, _ = mimetypes.guess_type(filename) return content, cached["voice"] @staticmethod diff --git a/homeassistant/components/tts/manifest.json b/homeassistant/components/tts/manifest.json index f1120ed2750..f379dc01dee 100644 --- a/homeassistant/components/tts/manifest.json +++ b/homeassistant/components/tts/manifest.json @@ -2,8 +2,8 @@ "domain": "tts", "name": "Text-to-speech (TTS)", "after_dependencies": ["media_player"], - "codeowners": ["@home-assistant/core", "@pvizeli"], - "dependencies": ["http"], + "codeowners": ["@home-assistant/core"], + "dependencies": ["http", "ffmpeg"], "documentation": "https://www.home-assistant.io/integrations/tts", "integration_type": "entity", "loggers": ["mutagen"], diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 509e7e17013..276d21f3821 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -14,12 +14,10 @@ from tuya_iot import ( TuyaOpenMQ, ) -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import dispatcher_send from .const import ( @@ -30,14 +28,12 @@ from .const import ( CONF_COUNTRY_CODE, CONF_ENDPOINT, CONF_PASSWORD, - CONF_PROJECT_TYPE, CONF_USERNAME, DOMAIN, LOGGER, PLATFORMS, TUYA_DISCOVERY_NEW, TUYA_HA_SIGNAL_UPDATE_ENTITY, - DPCode, ) @@ -53,13 +49,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Async setup hass config entry.""" hass.data.setdefault(DOMAIN, {}) - # Project type has been renamed to auth type in the upstream Tuya IoT SDK. - # This migrates existing config entries to reflect that name change. - if CONF_PROJECT_TYPE in entry.data: - data = {**entry.data, CONF_AUTH_TYPE: entry.data[CONF_PROJECT_TYPE]} - data.pop(CONF_PROJECT_TYPE) - hass.config_entries.async_update_entry(entry, data=data) - auth_type = AuthType(entry.data[CONF_AUTH_TYPE]) api = TuyaOpenAPI( endpoint=entry.data[CONF_ENDPOINT], @@ -108,9 +97,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.async_add_executor_job(home_manager.update_device_cache) await cleanup_device_registry(hass, device_manager) - # Migrate old unique_ids to the new format - async_migrate_entities_unique_ids(hass, entry, device_manager) - # Register known device IDs device_registry = dr.async_get(hass) for device in device_manager.device_map.values(): @@ -139,83 +125,6 @@ async def cleanup_device_registry( break -@callback -def async_migrate_entities_unique_ids( - hass: HomeAssistant, config_entry: ConfigEntry, device_manager: TuyaDeviceManager -) -> None: - """Migrate unique_ids in the entity registry to the new format.""" - entity_registry = er.async_get(hass) - registry_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id - ) - light_entries = { - entry.unique_id: entry - for entry in registry_entries - if entry.domain == LIGHT_DOMAIN - } - switch_entries = { - entry.unique_id: entry - for entry in registry_entries - if entry.domain == SWITCH_DOMAIN - } - - for device in device_manager.device_map.values(): - # Old lights where in `tuya.{device_id}` format, now the DPCode is added. - # - # If the device is a previously supported light category and still has - # the old format for the unique ID, migrate it to the new format. - # - # Previously only devices providing the SWITCH_LED DPCode were supported, - # thus this can be added to those existing IDs. - # - # `tuya.{device_id}` -> `tuya.{device_id}{SWITCH_LED}` - if ( - device.category in ("dc", "dd", "dj", "fs", "fwl", "jsq", "xdd", "xxj") - and (entry := light_entries.get(f"tuya.{device.id}")) - and f"tuya.{device.id}{DPCode.SWITCH_LED}" not in light_entries - ): - entity_registry.async_update_entity( - entry.entity_id, new_unique_id=f"tuya.{device.id}{DPCode.SWITCH_LED}" - ) - - # Old switches has different formats for the unique ID, but is mappable. - # - # If the device is a previously supported switch category and still has - # the old format for the unique ID, migrate it to the new format. - # - # `tuya.{device_id}` -> `tuya.{device_id}{SWITCH}` - # `tuya.{device_id}_1` -> `tuya.{device_id}{SWITCH_1}` - # ... - # `tuya.{device_id}_6` -> `tuya.{device_id}{SWITCH_6}` - # `tuya.{device_id}_usb1` -> `tuya.{device_id}{SWITCH_USB1}` - # ... - # `tuya.{device_id}_usb6` -> `tuya.{device_id}{SWITCH_USB6}` - # - # In all other cases, the unique ID is not changed. - if device.category in ("bh", "cwysj", "cz", "dlq", "kg", "kj", "pc", "xxj"): - for postfix, dpcode in ( - ("", DPCode.SWITCH), - ("_1", DPCode.SWITCH_1), - ("_2", DPCode.SWITCH_2), - ("_3", DPCode.SWITCH_3), - ("_4", DPCode.SWITCH_4), - ("_5", DPCode.SWITCH_5), - ("_6", DPCode.SWITCH_6), - ("_usb1", DPCode.SWITCH_USB1), - ("_usb2", DPCode.SWITCH_USB2), - ("_usb3", DPCode.SWITCH_USB3), - ("_usb4", DPCode.SWITCH_USB4), - ("_usb5", DPCode.SWITCH_USB5), - ("_usb6", DPCode.SWITCH_USB6), - ): - if ( - entry := switch_entries.get(f"tuya.{device.id}{postfix}") - ) and f"tuya.{device.id}{dpcode}" not in switch_entries: - entity_registry.async_update_entity( - entry.entity_id, new_unique_id=f"tuya.{device.id}{dpcode}" - ) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unloading the Tuya platforms.""" unload = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index acf9f8bbd2c..19faa76a191 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -338,6 +338,7 @@ class DPCode(StrEnum): TEMP_VALUE_V2 = "temp_value_v2" TEMPER_ALARM = "temper_alarm" # Tamper alarm TIME_TOTAL = "time_total" + TIME_USE = "time_use" # Total seconds of irrigation TOTAL_CLEAN_AREA = "total_clean_area" TOTAL_CLEAN_COUNT = "total_clean_count" TOTAL_CLEAN_TIME = "total_clean_time" @@ -362,6 +363,7 @@ class DPCode(StrEnum): WATER_RESET = "water_reset" # Resetting of water usage days WATER_SET = "water_set" # Water level WATERSENSOR_STATE = "watersensor_state" + WEATHER_DELAY = "weather_delay" WET = "wet" # Humidification WINDOW_CHECK = "window_check" WINDOW_STATE = "window_state" diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 3cc8c72f555..bc44ddf479c 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -75,6 +75,16 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { icon="mdi:thermometer-lines", ), ), + # Smart Water Timer + "sfkzq": ( + # Irrigation will not be run within this set delay period + SelectEntityDescription( + key=DPCode.WEATHER_DELAY, + translation_key="weather_delay", + icon="mdi:weather-cloudy-clock", + entity_category=EntityCategory.CONFIG, + ), + ), # Siren Alarm # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu "sgbj": ( diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 9f055a6262e..4bf8808f5f1 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -517,6 +517,18 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), + # Smart Water Timer + "sfkzq": ( + # Total seconds of irrigation. Read-write value; the device appears to ignore the write action (maybe firmware bug) + TuyaSensorEntityDescription( + key=DPCode.TIME_USE, + translation_key="total_watering_time", + icon="mdi:history", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + *BATTERY_SENSORS, + ), # Water Detector # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli "sj": BATTERY_SENSORS, @@ -818,6 +830,27 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { native_unit_of_measurement=UnitOfElectricPotential.VOLT, subkey="voltage", ), + TuyaSensorEntityDescription( + key=DPCode.CUR_CURRENT, + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_POWER, + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_VOLTAGE, + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), ), # Robot Vacuum # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 9c807419551..e9b13e10a95 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -421,6 +421,19 @@ "4": "Mood 4", "5": "Mood 5" } + }, + "weather_delay": { + "name": "Weather delay", + "state": { + "cancel": "Cancel", + "24h": "24h", + "48h": "48h", + "72h": "72h", + "96h": "96h", + "120h": "120h", + "144h": "144h", + "168h": "168h" + } } }, "sensor": { @@ -556,6 +569,9 @@ "water_level": { "name": "Water level" }, + "total_watering_time": { + "name": "Total watering time" + }, "filter_utilization": { "name": "Filter utilization" }, diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index a48d797555c..ba304b4069e 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -430,6 +430,14 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Smart Water Timer + "sfkzq": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + icon="mdi:sprinkler-variant", + ), + ), # Siren Alarm # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu "sgbj": ( diff --git a/homeassistant/components/twentemilieu/manifest.json b/homeassistant/components/twentemilieu/manifest.json index 6cb98444be6..aef70aa6a10 100644 --- a/homeassistant/components/twentemilieu/manifest.json +++ b/homeassistant/components/twentemilieu/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["twentemilieu"], "quality_scale": "platinum", - "requirements": ["twentemilieu==2.0.0"] + "requirements": ["twentemilieu==2.0.1"] } diff --git a/homeassistant/components/twentemilieu/sensor.py b/homeassistant/components/twentemilieu/sensor.py index fba10a269f7..1278f6523a5 100644 --- a/homeassistant/components/twentemilieu/sensor.py +++ b/homeassistant/components/twentemilieu/sensor.py @@ -21,20 +21,13 @@ from .const import DOMAIN from .entity import TwenteMilieuEntity -@dataclass -class TwenteMilieuSensorDescriptionMixin: - """Define an entity description mixin.""" +@dataclass(kw_only=True) +class TwenteMilieuSensorDescription(SensorEntityDescription): + """Describe an Twente Milieu sensor.""" waste_type: WasteType -@dataclass -class TwenteMilieuSensorDescription( - SensorEntityDescription, TwenteMilieuSensorDescriptionMixin -): - """Describe an Ambient PWS binary sensor.""" - - SENSORS: tuple[TwenteMilieuSensorDescription, ...] = ( TwenteMilieuSensorDescription( key="tree", diff --git a/homeassistant/components/twinkly/strings.json b/homeassistant/components/twinkly/strings.json index 9b4c8ebd778..88bc67abbbd 100644 --- a/homeassistant/components/twinkly/strings.json +++ b/homeassistant/components/twinkly/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Twinkly device." } }, "discovery_confirm": { diff --git a/homeassistant/components/twitch/strings.json b/homeassistant/components/twitch/strings.json index 45f88747128..f4128a15adc 100644 --- a/homeassistant/components/twitch/strings.json +++ b/homeassistant/components/twitch/strings.json @@ -10,7 +10,11 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "wrong_account": "Wrong account: Please authenticate with {username}." + "wrong_account": "Wrong account: Please authenticate with {username}.", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" } }, "issues": { diff --git a/homeassistant/components/unifi/button.py b/homeassistant/components/unifi/button.py index 7471675123a..af7ab5852ab 100644 --- a/homeassistant/components/unifi/button.py +++ b/homeassistant/components/unifi/button.py @@ -11,8 +11,14 @@ from typing import Any, Generic import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.interfaces.devices import Devices +from aiounifi.interfaces.ports import Ports from aiounifi.models.api import ApiItemT -from aiounifi.models.device import Device, DeviceRestartRequest +from aiounifi.models.device import ( + Device, + DevicePowerCyclePortRequest, + DeviceRestartRequest, +) +from aiounifi.models.port import Port from homeassistant.components.button import ( ButtonDeviceClass, @@ -42,6 +48,15 @@ async def async_restart_device_control_fn( await api.request(DeviceRestartRequest.create(obj_id)) +@callback +async def async_power_cycle_port_control_fn( + api: aiounifi.Controller, obj_id: str +) -> None: + """Restart device.""" + mac, _, index = obj_id.partition("_") + await api.request(DevicePowerCyclePortRequest.create(mac, int(index))) + + @dataclass class UnifiButtonEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" @@ -77,6 +92,24 @@ ENTITY_DESCRIPTIONS: tuple[UnifiButtonEntityDescription, ...] = ( supported_fn=lambda controller, obj_id: True, unique_id_fn=lambda controller, obj_id: f"device_restart-{obj_id}", ), + UnifiButtonEntityDescription[Ports, Port]( + key="PoE power cycle", + entity_category=EntityCategory.CONFIG, + has_entity_name=True, + device_class=ButtonDeviceClass.RESTART, + allowed_fn=lambda controller, obj_id: True, + api_handler_fn=lambda api: api.ports, + available_fn=async_device_available_fn, + control_fn=async_power_cycle_port_control_fn, + device_info_fn=async_device_device_info_fn, + event_is_on=None, + event_to_subscribe=None, + name_fn=lambda port: f"{port.name} Power Cycle", + object_fn=lambda api, obj_id: api.ports[obj_id], + should_poll=False, + supported_fn=lambda controller, obj_id: controller.api.ports[obj_id].port_poe, + unique_id_fn=lambda controller, obj_id: f"power_cycle-{obj_id}", + ), ) diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index a678517eca9..e1867b2df2e 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -8,6 +8,7 @@ Configuration of options through options flow. from __future__ import annotations from collections.abc import Mapping +import operator import socket from types import MappingProxyType from typing import Any @@ -309,6 +310,11 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): client.mac: f"{client.name or client.hostname} ({client.mac})" for client in self.controller.api.clients.values() } + clients |= { + mac: f"Unknown ({mac})" + for mac in self.options.get(CONF_CLIENT_SOURCE, []) + if mac not in clients + } return self.async_show_form( step_id="configure_entity_sources", @@ -317,7 +323,9 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): vol.Optional( CONF_CLIENT_SOURCE, default=self.options.get(CONF_CLIENT_SOURCE, []), - ): cv.multi_select(clients), + ): cv.multi_select( + dict(sorted(clients.items(), key=operator.itemgetter(1))) + ), } ), last_step=False, diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index b89e64f285f..035cf66a983 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -5,7 +5,7 @@ import asyncio from datetime import datetime, timedelta import ssl from types import MappingProxyType -from typing import Any +from typing import Any, Literal from aiohttp import CookieJar import aiounifi @@ -458,7 +458,7 @@ async def get_unifi_controller( config: MappingProxyType[str, Any], ) -> aiounifi.Controller: """Create a controller object and verify authentication.""" - ssl_context: ssl.SSLContext | bool = False + ssl_context: ssl.SSLContext | Literal[False] = False if verify_ssl := config.get(CONF_VERIFY_SSL): session = aiohttp_client.async_get_clientsession(hass) @@ -506,6 +506,14 @@ async def get_unifi_controller( ) raise CannotConnect from err + except aiounifi.Forbidden as err: + LOGGER.warning( + "Access forbidden to UniFi Network at %s, check access rights: %s", + config[CONF_HOST], + err, + ) + raise AuthenticationRequired from err + except aiounifi.LoginRequired as err: LOGGER.warning( "Connected to UniFi Network at %s but login required: %s", diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 5c9694c669c..1be52b97974 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -17,14 +17,15 @@ from aiounifi.models.client import Client from aiounifi.models.device import Device from aiounifi.models.event import Event, EventKey -from homeassistant.components.device_tracker import ScannerEntity, SourceType +from homeassistant.components.device_tracker import DOMAIN, ScannerEntity, SourceType from homeassistant.config_entries import ConfigEntry from homeassistant.core import Event as core_Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.entity_registry as er import homeassistant.util.dt as dt_util -from .controller import UniFiController +from .controller import UNIFI_DOMAIN, UniFiController from .entity import ( HandlerT, UnifiEntity, @@ -175,7 +176,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiTrackerEntityDescription, ...] = ( object_fn=lambda api, obj_id: api.clients[obj_id], should_poll=False, supported_fn=lambda controller, obj_id: True, - unique_id_fn=lambda controller, obj_id: f"{obj_id}-{controller.site}", + unique_id_fn=lambda controller, obj_id: f"{controller.site}-{obj_id}", ip_address_fn=lambda api, obj_id: api.clients[obj_id].ip, hostname_fn=lambda api, obj_id: api.clients[obj_id].hostname, ), @@ -201,12 +202,37 @@ ENTITY_DESCRIPTIONS: tuple[UnifiTrackerEntityDescription, ...] = ( ) +@callback +def async_update_unique_id(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Normalize client unique ID to have a prefix rather than suffix. + + Introduced with release 2023.12. + """ + controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + ent_reg = er.async_get(hass) + + @callback + def update_unique_id(obj_id: str) -> None: + """Rework unique ID.""" + new_unique_id = f"{controller.site}-{obj_id}" + if ent_reg.async_get_entity_id(DOMAIN, UNIFI_DOMAIN, new_unique_id): + return + + unique_id = f"{obj_id}-{controller.site}" + if entity_id := ent_reg.async_get_entity_id(DOMAIN, UNIFI_DOMAIN, unique_id): + ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) + + for obj_id in list(controller.api.clients) + list(controller.api.clients_all): + update_unique_id(obj_id) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up device tracker for UniFi Network integration.""" + async_update_unique_id(hass, config_entry) UniFiController.register_platform( hass, config_entry, async_add_entities, UnifiScannerEntity, ENTITY_DESCRIPTIONS ) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index ed8649896dd..7d4717d3fff 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==65"], + "requirements": ["aiounifi==67"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 3d0ffa1896e..4d5cf49b5c9 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -151,6 +151,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( UnifiSensorEntityDescription[Clients, Client]( key="Bandwidth sensor RX", device_class=SensorDeviceClass.DATA_RATE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, icon="mdi:upload", @@ -171,6 +172,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( UnifiSensorEntityDescription[Clients, Client]( key="Bandwidth sensor TX", device_class=SensorDeviceClass.DATA_RATE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, icon="mdi:download", @@ -231,6 +233,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( key="WLAN clients", entity_category=EntityCategory.DIAGNOSTIC, has_entity_name=True, + state_class=SensorStateClass.MEASUREMENT, allowed_fn=lambda controller, obj_id: True, api_handler_fn=lambda api: api.wlans, available_fn=async_wlan_available_fn, diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index 9c609ca8c07..ba426c2f08a 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -11,6 +11,9 @@ "port": "[%key:common::config_flow::data::port%]", "site": "Site ID", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "Hostname or IP address of your UniFi Network." } } }, diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 41c1f55a22a..1e9ec8b14c8 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -42,9 +42,10 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.entity_registry as er from .const import ATTR_MANUFACTURER -from .controller import UniFiController +from .controller import UNIFI_DOMAIN, UniFiController from .entity import ( HandlerT, SubscriptionT, @@ -256,7 +257,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( object_fn=lambda api, obj_id: api.outlets[obj_id], should_poll=False, supported_fn=async_outlet_supports_switching_fn, - unique_id_fn=lambda controller, obj_id: f"{obj_id.split('_', 1)[0]}-outlet-{obj_id.split('_', 1)[1]}", + unique_id_fn=lambda controller, obj_id: f"outlet-{obj_id}", ), UnifiSwitchEntityDescription[PortForwarding, PortForward]( key="Port forward control", @@ -297,7 +298,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( object_fn=lambda api, obj_id: api.ports[obj_id], should_poll=False, supported_fn=lambda controller, obj_id: controller.api.ports[obj_id].port_poe, - unique_id_fn=lambda controller, obj_id: f"{obj_id.split('_', 1)[0]}-poe-{obj_id.split('_', 1)[1]}", + unique_id_fn=lambda controller, obj_id: f"poe-{obj_id}", ), UnifiSwitchEntityDescription[Wlans, Wlan]( key="WLAN control", @@ -322,12 +323,41 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( ) +@callback +def async_update_unique_id(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Normalize switch unique ID to have a prefix rather than midfix. + + Introduced with release 2023.12. + """ + controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + ent_reg = er.async_get(hass) + + @callback + def update_unique_id(obj_id: str, type_name: str) -> None: + """Rework unique ID.""" + new_unique_id = f"{type_name}-{obj_id}" + if ent_reg.async_get_entity_id(DOMAIN, UNIFI_DOMAIN, new_unique_id): + return + + prefix, _, suffix = obj_id.partition("_") + unique_id = f"{prefix}-{type_name}-{suffix}" + if entity_id := ent_reg.async_get_entity_id(DOMAIN, UNIFI_DOMAIN, unique_id): + ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) + + for obj_id in controller.api.outlets: + update_unique_id(obj_id, "outlet") + + for obj_id in controller.api.ports: + update_unique_id(obj_id, "poe") + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches for UniFi Network integration.""" + async_update_unique_id(hass, config_entry) UniFiController.register_platform( hass, config_entry, diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 73ac6e08c17..a345a504c42 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -11,6 +11,9 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "Hostname or IP address of your UniFi Protect device." } }, "reauth_confirm": { diff --git a/homeassistant/components/universal/manifest.json b/homeassistant/components/universal/manifest.json index 587d2c7aad2..4cf52892aaf 100644 --- a/homeassistant/components/universal/manifest.json +++ b/homeassistant/components/universal/manifest.json @@ -1,6 +1,6 @@ { "domain": "universal", - "name": "Universal Media Player", + "name": "Universal media player", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/universal", "iot_class": "calculated", diff --git a/homeassistant/components/update/strings.json b/homeassistant/components/update/strings.json index 1d238d3dd51..eb6db257bb2 100644 --- a/homeassistant/components/update/strings.json +++ b/homeassistant/components/update/strings.json @@ -9,7 +9,45 @@ }, "entity_component": { "_": { - "name": "[%key:component::update::title%]" + "name": "[%key:component::update::title%]", + "state": { + "on": "Update available", + "off": "Up-to-date" + }, + "state_attributes": { + "in_progress": { + "name": "In progress", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "auto_update": { + "name": "Auto update", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "title": { + "name": "Title" + }, + "skipped_version": { + "name": "Skipped version" + }, + "release_url": { + "name": "Release URL" + }, + "release_summary": { + "name": "Release summary" + }, + "installed_version": { + "name": "Installed version" + }, + "latest_version": { + "name": "Latest version" + } + } }, "firmware": { "name": "Firmware" diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 326ff5d7651..6af9d85bc87 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -26,7 +26,7 @@ from .const import ( LOGGER, ) from .coordinator import UpnpDataUpdateCoordinator -from .device import async_create_device +from .device import async_create_device, get_preferred_location NOTIFICATION_ID = "upnp_notification" NOTIFICATION_TITLE = "UPnP/IGD Setup" @@ -57,7 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return nonlocal discovery_info - LOGGER.debug("Device discovered: %s, at: %s", usn, headers.ssdp_location) + LOGGER.debug("Device discovered: %s, at: %s", usn, headers.ssdp_all_locations) discovery_info = headers device_discovered_event.set() @@ -79,8 +79,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Create device. assert discovery_info is not None - assert discovery_info.ssdp_location is not None - location = discovery_info.ssdp_location + assert discovery_info.ssdp_all_locations + location = get_preferred_location(discovery_info.ssdp_all_locations) try: device = await async_create_device(hass, location) except UpnpConnectionError as err: diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 35d66536375..b32273a3f24 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any, cast +from urllib.parse import urlparse import voluptuous as vol @@ -25,7 +26,7 @@ from .const import ( ST_IGD_V1, ST_IGD_V2, ) -from .device import async_get_mac_address_from_host +from .device import async_get_mac_address_from_host, get_preferred_location def _friendly_name_from_discovery(discovery_info: ssdp.SsdpServiceInfo) -> str: @@ -43,7 +44,7 @@ def _is_complete_discovery(discovery_info: ssdp.SsdpServiceInfo) -> bool: return bool( ssdp.ATTR_UPNP_UDN in discovery_info.upnp and discovery_info.ssdp_st - and discovery_info.ssdp_location + and discovery_info.ssdp_all_locations and discovery_info.ssdp_usn ) @@ -61,7 +62,9 @@ async def _async_mac_address_from_discovery( hass: HomeAssistant, discovery: SsdpServiceInfo ) -> str | None: """Get the mac address from a discovery.""" - host = discovery.ssdp_headers["_host"] + location = get_preferred_location(discovery.ssdp_all_locations) + host = urlparse(location).hostname + assert host is not None return await async_get_mac_address_from_host(hass, host) @@ -178,7 +181,9 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # when the location changes, the entry is reloaded. updates={ CONFIG_ENTRY_MAC_ADDRESS: mac_address, - CONFIG_ENTRY_LOCATION: discovery_info.ssdp_location, + CONFIG_ENTRY_LOCATION: get_preferred_location( + discovery_info.ssdp_all_locations + ), CONFIG_ENTRY_HOST: host, CONFIG_ENTRY_ST: discovery_info.ssdp_st, }, @@ -249,7 +254,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): CONFIG_ENTRY_ORIGINAL_UDN: discovery.upnp[ssdp.ATTR_UPNP_UDN], CONFIG_ENTRY_MAC_ADDRESS: mac_address, CONFIG_ENTRY_HOST: discovery.ssdp_headers["_host"], - CONFIG_ENTRY_LOCATION: discovery.ssdp_location, + CONFIG_ENTRY_LOCATION: get_preferred_location(discovery.ssdp_all_locations), } await self.async_set_unique_id(user_input["unique_id"], raise_on_progress=False) @@ -271,7 +276,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): CONFIG_ENTRY_UDN: discovery.upnp[ssdp.ATTR_UPNP_UDN], CONFIG_ENTRY_ST: discovery.ssdp_st, CONFIG_ENTRY_ORIGINAL_UDN: discovery.upnp[ssdp.ATTR_UPNP_UDN], - CONFIG_ENTRY_LOCATION: discovery.ssdp_location, + CONFIG_ENTRY_LOCATION: get_preferred_location(discovery.ssdp_all_locations), CONFIG_ENTRY_MAC_ADDRESS: mac_address, CONFIG_ENTRY_HOST: discovery.ssdp_headers["_host"], } diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index b62edbf9bc2..93f551bea37 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -33,6 +33,22 @@ from .const import ( ) +def get_preferred_location(locations: set[str]) -> str: + """Get the preferred location (an IPv4 location) from a set of locations.""" + # Prefer IPv4 over IPv6. + for location in locations: + if location.startswith("http://[") or location.startswith("https://["): + continue + + return location + + # Fallback to any. + for location in locations: + return location + + raise ValueError("No location found") + + async def async_get_mac_address_from_host(hass: HomeAssistant, host: str) -> str | None: """Get mac address from host.""" ip_addr = ip_address(host) @@ -47,13 +63,13 @@ async def async_get_mac_address_from_host(hass: HomeAssistant, host: str) -> str return mac_address -async def async_create_device(hass: HomeAssistant, ssdp_location: str) -> Device: +async def async_create_device(hass: HomeAssistant, location: str) -> Device: """Create UPnP/IGD device.""" session = async_get_clientsession(hass, verify_ssl=False) requester = AiohttpSessionRequester(session, with_sleep=True, timeout=20) factory = UpnpFactory(requester, non_strict=True) - upnp_device = await factory.async_create_device(ssdp_location) + upnp_device = await factory.async_create_device(location) # Create profile wrapper. igd_device = IgdDevice(upnp_device, None) @@ -119,8 +135,7 @@ class Device: @property def host(self) -> str | None: """Get the hostname.""" - url = self._igd_device.device.device_url - parsed = urlparse(url) + parsed = urlparse(self.device_url) return parsed.hostname @property diff --git a/homeassistant/components/v2c/__init__.py b/homeassistant/components/v2c/__init__.py new file mode 100644 index 00000000000..3cf615caa3c --- /dev/null +++ b/homeassistant/components/v2c/__init__.py @@ -0,0 +1,43 @@ +"""The V2C integration.""" +from __future__ import annotations + +from pytrydan import Trydan + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.httpx_client import get_async_client + +from .const import DOMAIN +from .coordinator import V2CUpdateCoordinator + +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, + Platform.SWITCH, + Platform.NUMBER, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up V2C from a config entry.""" + + host = entry.data[CONF_HOST] + trydan = Trydan(host, get_async_client(hass, verify_ssl=False)) + coordinator = V2CUpdateCoordinator(hass, trydan, host) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/v2c/binary_sensor.py b/homeassistant/components/v2c/binary_sensor.py new file mode 100644 index 00000000000..7776a3398c7 --- /dev/null +++ b/homeassistant/components/v2c/binary_sensor.py @@ -0,0 +1,90 @@ +"""Support for V2C binary sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from pytrydan import Trydan + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import V2CUpdateCoordinator +from .entity import V2CBaseEntity + + +@dataclass +class V2CRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Trydan], bool] + + +@dataclass +class V2CBinarySensorEntityDescription( + BinarySensorEntityDescription, V2CRequiredKeysMixin +): + """Describes an EVSE binary sensor entity.""" + + +TRYDAN_SENSORS = ( + V2CBinarySensorEntityDescription( + key="connected", + translation_key="connected", + device_class=BinarySensorDeviceClass.PLUG, + value_fn=lambda evse: evse.connected, + ), + V2CBinarySensorEntityDescription( + key="charging", + translation_key="charging", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + value_fn=lambda evse: evse.charging, + ), + V2CBinarySensorEntityDescription( + key="ready", + translation_key="ready", + value_fn=lambda evse: evse.ready, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up V2C binary sensor platform.""" + coordinator: V2CUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + V2CBinarySensorBaseEntity(coordinator, description, config_entry.entry_id) + for description in TRYDAN_SENSORS + ) + + +class V2CBinarySensorBaseEntity(V2CBaseEntity, BinarySensorEntity): + """Defines a base V2C binary_sensor entity.""" + + entity_description: V2CBinarySensorEntityDescription + + def __init__( + self, + coordinator: V2CUpdateCoordinator, + description: V2CBinarySensorEntityDescription, + entry_id: str, + ) -> None: + """Init the V2C base entity.""" + super().__init__(coordinator, description) + self._attr_unique_id = f"{entry_id}_{description.key}" + + @property + def is_on(self) -> bool: + """Return the state of the V2C binary_sensor.""" + return self.entity_description.value_fn(self.coordinator.evse) diff --git a/homeassistant/components/v2c/config_flow.py b/homeassistant/components/v2c/config_flow.py new file mode 100644 index 00000000000..382b41d3994 --- /dev/null +++ b/homeassistant/components/v2c/config_flow.py @@ -0,0 +1,57 @@ +"""Config flow for V2C integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from pytrydan import Trydan +from pytrydan.exceptions import TrydanError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.httpx_client import get_async_client + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for V2C.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + evse = Trydan( + user_input[CONF_HOST], + client=get_async_client(self.hass, verify_ssl=False), + ) + + try: + await evse.get_data() + except TrydanError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=f"EVSE {user_input[CONF_HOST]}", data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/v2c/const.py b/homeassistant/components/v2c/const.py new file mode 100644 index 00000000000..b568368f718 --- /dev/null +++ b/homeassistant/components/v2c/const.py @@ -0,0 +1,3 @@ +"""Constants for the V2C integration.""" + +DOMAIN = "v2c" diff --git a/homeassistant/components/v2c/coordinator.py b/homeassistant/components/v2c/coordinator.py new file mode 100644 index 00000000000..f61d58b844d --- /dev/null +++ b/homeassistant/components/v2c/coordinator.py @@ -0,0 +1,41 @@ +"""The v2c component.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from pytrydan import Trydan, TrydanData +from pytrydan.exceptions import TrydanError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +SCAN_INTERVAL = timedelta(seconds=5) + +_LOGGER = logging.getLogger(__name__) + + +class V2CUpdateCoordinator(DataUpdateCoordinator[TrydanData]): + """DataUpdateCoordinator to gather data from any v2c.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, evse: Trydan, host: str) -> None: + """Initialize DataUpdateCoordinator for a v2c evse.""" + self.evse = evse + super().__init__( + hass, + _LOGGER, + name=f"EVSE {host}", + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> TrydanData: + """Fetch sensor data from api.""" + try: + data: TrydanData = await self.evse.get_data() + except TrydanError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + _LOGGER.debug("Received data: %s", data) + return data diff --git a/homeassistant/components/v2c/entity.py b/homeassistant/components/v2c/entity.py new file mode 100644 index 00000000000..ee3c94d8d0c --- /dev/null +++ b/homeassistant/components/v2c/entity.py @@ -0,0 +1,41 @@ +"""Support for V2C EVSE.""" +from __future__ import annotations + +from pytrydan import TrydanData + +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 V2CUpdateCoordinator + + +class V2CBaseEntity(CoordinatorEntity[V2CUpdateCoordinator]): + """Defines a base v2c entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: V2CUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Init the V2C base entity.""" + self.entity_description = description + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + manufacturer="V2C", + model="Trydan", + name=coordinator.name, + sw_version=coordinator.evse.firmware_version, + ) + + @property + def data(self) -> TrydanData: + """Return v2c evse data.""" + data = self.coordinator.data + assert data is not None + return data diff --git a/homeassistant/components/v2c/manifest.json b/homeassistant/components/v2c/manifest.json new file mode 100644 index 00000000000..ce0e9d7b847 --- /dev/null +++ b/homeassistant/components/v2c/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "v2c", + "name": "V2C", + "codeowners": ["@dgomes"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/v2c", + "iot_class": "local_polling", + "requirements": ["pytrydan==0.4.0"] +} diff --git a/homeassistant/components/v2c/number.py b/homeassistant/components/v2c/number.py new file mode 100644 index 00000000000..0f2551818a2 --- /dev/null +++ b/homeassistant/components/v2c/number.py @@ -0,0 +1,92 @@ +"""Number platform for V2C settings.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from pytrydan import Trydan, TrydanData + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +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 V2CUpdateCoordinator +from .entity import V2CBaseEntity + +MIN_INTENSITY = 6 +MAX_INTENSITY = 32 + + +@dataclass +class V2CSettingsRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[TrydanData], int] + update_fn: Callable[[Trydan, int], Coroutine[Any, Any, None]] + + +@dataclass +class V2CSettingsNumberEntityDescription( + NumberEntityDescription, V2CSettingsRequiredKeysMixin +): + """Describes V2C EVSE number entity.""" + + +TRYDAN_NUMBER_SETTINGS = ( + V2CSettingsNumberEntityDescription( + key="intensity", + translation_key="intensity", + device_class=NumberDeviceClass.CURRENT, + native_min_value=MIN_INTENSITY, + native_max_value=MAX_INTENSITY, + value_fn=lambda evse_data: evse_data.intensity, + update_fn=lambda evse, value: evse.intensity(value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up V2C Trydan number platform.""" + coordinator: V2CUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + V2CSettingsNumberEntity(coordinator, description, config_entry.entry_id) + for description in TRYDAN_NUMBER_SETTINGS + ) + + +class V2CSettingsNumberEntity(V2CBaseEntity, NumberEntity): + """Representation of V2C EVSE settings number entity.""" + + entity_description: V2CSettingsNumberEntityDescription + + def __init__( + self, + coordinator: V2CUpdateCoordinator, + description: V2CSettingsNumberEntityDescription, + entry_id: str, + ) -> None: + """Initialize the V2C number entity.""" + super().__init__(coordinator, description) + self._attr_unique_id = f"{entry_id}_{description.key}" + + @property + def native_value(self) -> float: + """Return the state of the setting entity.""" + return self.entity_description.value_fn(self.data) + + async def async_set_native_value(self, value: float) -> None: + """Update the setting.""" + await self.entity_description.update_fn(self.coordinator.evse, int(value)) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/v2c/sensor.py b/homeassistant/components/v2c/sensor.py new file mode 100644 index 00000000000..0c860943922 --- /dev/null +++ b/homeassistant/components/v2c/sensor.py @@ -0,0 +1,117 @@ +"""Support for V2C EVSE sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from pytrydan import TrydanData + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfEnergy, UnitOfPower, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import V2CUpdateCoordinator +from .entity import V2CBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class V2CRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[TrydanData], float] + + +@dataclass +class V2CSensorEntityDescription(SensorEntityDescription, V2CRequiredKeysMixin): + """Describes an EVSE Power sensor entity.""" + + +TRYDAN_SENSORS = ( + V2CSensorEntityDescription( + key="charge_power", + translation_key="charge_power", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + value_fn=lambda evse_data: evse_data.charge_power, + ), + V2CSensorEntityDescription( + key="charge_energy", + translation_key="charge_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + value_fn=lambda evse_data: evse_data.charge_energy, + ), + V2CSensorEntityDescription( + key="charge_time", + translation_key="charge_time", + native_unit_of_measurement=UnitOfTime.SECONDS, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.DURATION, + value_fn=lambda evse_data: evse_data.charge_time, + ), + V2CSensorEntityDescription( + key="house_power", + translation_key="house_power", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + value_fn=lambda evse_data: evse_data.house_power, + ), + V2CSensorEntityDescription( + key="fv_power", + translation_key="fv_power", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + value_fn=lambda evse_data: evse_data.fv_power, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up V2C sensor platform.""" + coordinator: V2CUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + V2CSensorBaseEntity(coordinator, description, config_entry.entry_id) + for description in TRYDAN_SENSORS + ) + + +class V2CSensorBaseEntity(V2CBaseEntity, SensorEntity): + """Defines a base v2c sensor entity.""" + + entity_description: V2CSensorEntityDescription + _attr_icon = "mdi:ev-station" + + def __init__( + self, + coordinator: V2CUpdateCoordinator, + description: SensorEntityDescription, + entry_id: str, + ) -> None: + """Initialize V2C Power entity.""" + super().__init__(coordinator, description) + self._attr_unique_id = f"{entry_id}_{description.key}" + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.data) diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json new file mode 100644 index 00000000000..bf19fe5188e --- /dev/null +++ b/homeassistant/components/v2c/strings.json @@ -0,0 +1,58 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your V2C Trydan EVSE." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "entity": { + "binary_sensor": { + "connected": { + "name": "Connected" + }, + "charging": { + "name": "Charging" + }, + "ready": { + "name": "Ready" + } + }, + "number": { + "intensity": { + "name": "Intensity" + } + }, + "sensor": { + "charge_power": { + "name": "Charge power" + }, + "charge_energy": { + "name": "Charge energy" + }, + "charge_time": { + "name": "Charge time" + }, + "house_power": { + "name": "House power" + }, + "fv_power": { + "name": "Photovoltaic power" + } + }, + "switch": { + "paused": { + "name": "Pause session" + } + } + } +} diff --git a/homeassistant/components/v2c/switch.py b/homeassistant/components/v2c/switch.py new file mode 100644 index 00000000000..4e56e72dcbf --- /dev/null +++ b/homeassistant/components/v2c/switch.py @@ -0,0 +1,92 @@ +"""Switch platform for V2C EVSE.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from pytrydan import Trydan, TrydanData +from pytrydan.models.trydan import PauseState + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +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 V2CUpdateCoordinator +from .entity import V2CBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class V2CRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[TrydanData], bool] + turn_on_fn: Callable[[Trydan], Coroutine[Any, Any, Any]] + turn_off_fn: Callable[[Trydan], Coroutine[Any, Any, Any]] + + +@dataclass +class V2CSwitchEntityDescription(SwitchEntityDescription, V2CRequiredKeysMixin): + """Describes a V2C EVSE switch entity.""" + + +TRYDAN_SWITCHES = ( + V2CSwitchEntityDescription( + key="paused", + translation_key="paused", + icon="mdi:pause", + value_fn=lambda evse_data: evse_data.paused == PauseState.PAUSED, + turn_on_fn=lambda evse: evse.pause(), + turn_off_fn=lambda evse: evse.resume(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up V2C switch platform.""" + coordinator: V2CUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + V2CSwitchEntity(coordinator, description, config_entry.entry_id) + for description in TRYDAN_SWITCHES + ) + + +class V2CSwitchEntity(V2CBaseEntity, SwitchEntity): + """Representation of a V2C switch entity.""" + + entity_description: V2CSwitchEntityDescription + + def __init__( + self, + coordinator: V2CUpdateCoordinator, + description: SwitchEntityDescription, + entry_id: str, + ) -> None: + """Initialize the V2C switch entity.""" + super().__init__(coordinator, description) + self._attr_unique_id = f"{entry_id}_{description.key}" + + @property + def is_on(self) -> bool: + """Return the state of the EVSE switch.""" + return self.entity_description.value_fn(self.data) + + async def async_turn_on(self): + """Turn on the EVSE switch.""" + await self.entity_description.turn_on_fn(self.coordinator.evse) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self): + """Turn off the EVSE switch.""" + await self.entity_description.turn_off_fn(self.coordinator.evse) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index 1feda8e694a..ce40e07e294 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -135,7 +135,7 @@ class ValloxState: @property def sw_version(self) -> str: """Return the SW version.""" - return cast(str, _api_get_sw_version(self.metric_cache)) + return _api_get_sw_version(self.metric_cache) @property def uuid(self) -> UUID | None: diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index 2f420096c74..e58c3ebd88d 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -11,11 +11,7 @@ from vallox_websocket_api import ( ValloxInvalidInputException, ) -from homeassistant.components.fan import ( - FanEntity, - FanEntityFeature, - NotValidPresetModeError, -) +from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -200,12 +196,6 @@ class ValloxFanEntity(ValloxEntity, FanEntity): Returns true if the mode has been changed, false otherwise. """ - try: - self._valid_preset_mode_or_raise(preset_mode) - - except NotValidPresetModeError as err: - raise ValueError(f"Not valid preset mode: {preset_mode}") from err - if preset_mode == self.preset_mode: return False diff --git a/homeassistant/components/vallox/manifest.json b/homeassistant/components/vallox/manifest.json index 479c84d238c..c06bc036e4e 100644 --- a/homeassistant/components/vallox/manifest.json +++ b/homeassistant/components/vallox/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vallox", "iot_class": "local_polling", "loggers": ["vallox_websocket_api"], - "requirements": ["vallox-websocket-api==3.3.0"] + "requirements": ["vallox-websocket-api==4.0.2"] } diff --git a/homeassistant/components/vallox/strings.json b/homeassistant/components/vallox/strings.json index acc6a31f158..e3ade9a55c4 100644 --- a/homeassistant/components/vallox/strings.json +++ b/homeassistant/components/vallox/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Vallox device." } } }, diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index b2b1cb31624..c23c1d5924e 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -119,9 +119,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Handle Memo Text service call.""" memo_text = call.data[CONF_MEMO_TEXT] memo_text.hass = hass - await hass.data[DOMAIN][call.data[CONF_INTERFACE]]["cntrl"].get_module( - call.data[CONF_ADDRESS] - ).set_memo_text(memo_text.async_render()) + await ( + hass.data[DOMAIN][call.data[CONF_INTERFACE]]["cntrl"] + .get_module(call.data[CONF_ADDRESS]) + .set_memo_text(memo_text.async_render()) + ) hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/venstar/strings.json b/homeassistant/components/venstar/strings.json index a844adc2156..92dfac211fb 100644 --- a/homeassistant/components/venstar/strings.json +++ b/homeassistant/components/venstar/strings.json @@ -2,13 +2,16 @@ "config": { "step": { "user": { - "title": "Connect to the Venstar Thermostat", + "description": "Connect to the Venstar thermostat", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "pin": "[%key:common::config_flow::data::pin%]", "ssl": "[%key:common::config_flow::data::ssl%]" + }, + "data_description": { + "host": "Hostname or IP address of your Venstar thermostat." } } }, diff --git a/homeassistant/components/verisure/manifest.json b/homeassistant/components/verisure/manifest.json index 70c0505929d..f6630f0c6e5 100644 --- a/homeassistant/components/verisure/manifest.json +++ b/homeassistant/components/verisure/manifest.json @@ -1,7 +1,7 @@ { "domain": "verisure", "name": "Verisure", - "codeowners": ["@frenck"], + "codeowners": [], "config_flow": true, "dhcp": [ { diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py index f3612c2d011..4277460c3ea 100644 --- a/homeassistant/components/vesync/sensor.py +++ b/homeassistant/components/vesync/sensor.py @@ -48,12 +48,12 @@ class VeSyncSensorEntityDescription( ): """Describe VeSync sensor entity.""" - exists_fn: Callable[ - [VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], bool - ] = lambda _: True - update_fn: Callable[ - [VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], None - ] = lambda _: None + exists_fn: Callable[[VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], bool] = ( + lambda _: True + ) + update_fn: Callable[[VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], None] = ( + lambda _: None + ) def update_energy(device): diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index 7a297ca8113..76de3a8a7ac 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -10,10 +10,15 @@ from typing import Any from PyViCare.PyViCare import PyViCare from PyViCare.PyViCareDevice import Device +from PyViCare.PyViCareUtils import ( + PyViCareInvalidConfigurationError, + PyViCareInvalidCredentialsError, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.storage import STORAGE_DIR from .const import ( @@ -53,7 +58,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN] = {} hass.data[DOMAIN][entry.entry_id] = {} - await hass.async_add_executor_job(setup_vicare_api, hass, entry) + try: + await hass.async_add_executor_job(setup_vicare_api, hass, entry) + except (PyViCareInvalidConfigurationError, PyViCareInvalidCredentialsError) as err: + raise ConfigEntryAuthFailed("Authentication failed") from err await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 4e3d8d05f97..525099e7d4e 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -5,7 +5,11 @@ from contextlib import suppress from dataclasses import dataclass import logging +from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from PyViCare.PyViCareHeatingDevice import ( + HeatingDeviceWithComponent as PyViCareHeatingDeviceWithComponent, +) from PyViCare.PyViCareUtils import ( PyViCareInvalidDataError, PyViCareNotSupportedFeatureError, @@ -25,7 +29,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ViCareRequiredKeysMixin from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG from .entity import ViCareEntity -from .utils import is_supported +from .utils import get_burners, get_circuits, get_compressors, is_supported _LOGGER = logging.getLogger(__name__) @@ -40,14 +44,14 @@ class ViCareBinarySensorEntityDescription( CIRCUIT_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ViCareBinarySensorEntityDescription( key="circulationpump_active", - name="Circulation pump", + translation_key="circulation_pump", icon="mdi:pump", device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getCirculationPumpActive(), ), ViCareBinarySensorEntityDescription( key="frost_protection_active", - name="Frost protection", + translation_key="frost_protection", icon="mdi:snowflake", value_getter=lambda api: api.getFrostProtectionActive(), ), @@ -56,7 +60,7 @@ CIRCUIT_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( BURNER_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ViCareBinarySensorEntityDescription( key="burner_active", - name="Burner", + translation_key="burner", icon="mdi:gas-burner", device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getActive(), @@ -66,7 +70,7 @@ BURNER_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( COMPRESSOR_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ViCareBinarySensorEntityDescription( key="compressor_active", - name="Compressor", + translation_key="compressor", device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getActive(), ), @@ -75,27 +79,27 @@ COMPRESSOR_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( GLOBAL_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ViCareBinarySensorEntityDescription( key="solar_pump_active", - name="Solar pump", + translation_key="solar_pump", icon="mdi:pump", device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getSolarPumpActive(), ), ViCareBinarySensorEntityDescription( key="charging_active", - name="DHW Charging", + translation_key="domestic_hot_water_charging", device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getDomesticHotWaterChargingActive(), ), ViCareBinarySensorEntityDescription( key="dhw_circulationpump_active", - name="DHW Circulation Pump", + translation_key="domestic_hot_water_circulation_pump", icon="mdi:pump", device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getDomesticHotWaterCirculationPumpActive(), ), ViCareBinarySensorEntityDescription( key="dhw_pump_active", - name="DHW Pump", + translation_key="domestic_hot_water_pump", icon="mdi:pump", device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getDomesticHotWaterPumpActive(), @@ -103,45 +107,67 @@ GLOBAL_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ) -def _build_entity( - name: str, - vicare_api, +def _build_entities( + device: PyViCareDevice, device_config: PyViCareDeviceConfig, - entity_description: ViCareBinarySensorEntityDescription, -): - """Create a ViCare binary sensor entity.""" - if is_supported(name, entity_description, vicare_api): - return ViCareBinarySensor( - name, - vicare_api, - device_config, - entity_description, +) -> list[ViCareBinarySensor]: + """Create ViCare binary sensor entities for a device.""" + + entities: list[ViCareBinarySensor] = _build_entities_for_device( + device, device_config + ) + entities.extend( + _build_entities_for_component( + get_circuits(device), device_config, CIRCUIT_SENSORS ) - return None + ) + entities.extend( + _build_entities_for_component( + get_burners(device), device_config, BURNER_SENSORS + ) + ) + entities.extend( + _build_entities_for_component( + get_compressors(device), device_config, COMPRESSOR_SENSORS + ) + ) + return entities -async def _entities_from_descriptions( - hass: HomeAssistant, - entities: list[ViCareBinarySensor], - sensor_descriptions: tuple[ViCareBinarySensorEntityDescription, ...], - iterables, - config_entry: ConfigEntry, -) -> None: - """Create entities from descriptions and list of burners/circuits.""" - for description in sensor_descriptions: - for current in iterables: - suffix = "" - if len(iterables) > 1: - suffix = f" {current.id}" - entity = await hass.async_add_executor_job( - _build_entity, - f"{description.name}{suffix}", - current, - hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], - description, - ) - if entity is not None: - entities.append(entity) +def _build_entities_for_device( + device: PyViCareDevice, + device_config: PyViCareDeviceConfig, +) -> list[ViCareBinarySensor]: + """Create device specific ViCare binary sensor entities.""" + + return [ + ViCareBinarySensor( + device, + device_config, + description, + ) + for description in GLOBAL_SENSORS + if is_supported(description.key, description, device) + ] + + +def _build_entities_for_component( + components: list[PyViCareHeatingDeviceWithComponent], + device_config: PyViCareDeviceConfig, + entity_descriptions: tuple[ViCareBinarySensorEntityDescription, ...], +) -> list[ViCareBinarySensor]: + """Create component specific ViCare binary sensor entities.""" + + return [ + ViCareBinarySensor( + component, + device_config, + description, + ) + for component in components + for description in entity_descriptions + if is_supported(description.key, description, component) + ] async def async_setup_entry( @@ -151,42 +177,15 @@ async def async_setup_entry( ) -> None: """Create the ViCare binary sensor devices.""" api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] + device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] - entities = [] - - for description in GLOBAL_SENSORS: - entity = await hass.async_add_executor_job( - _build_entity, - description.name, + async_add_entities( + await hass.async_add_executor_job( + _build_entities, api, - hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], - description, + device_config, ) - if entity is not None: - entities.append(entity) - - try: - await _entities_from_descriptions( - hass, entities, CIRCUIT_SENSORS, api.circuits, config_entry - ) - except PyViCareNotSupportedFeatureError: - _LOGGER.info("No circuits found") - - try: - await _entities_from_descriptions( - hass, entities, BURNER_SENSORS, api.burners, config_entry - ) - except PyViCareNotSupportedFeatureError: - _LOGGER.info("No burners found") - - try: - await _entities_from_descriptions( - hass, entities, COMPRESSOR_SENSORS, api.compressors, config_entry - ) - except PyViCareNotSupportedFeatureError: - _LOGGER.info("No compressors found") - - async_add_entities(entities) + ) class ViCareBinarySensor(ViCareEntity, BinarySensorEntity): @@ -195,31 +194,21 @@ class ViCareBinarySensor(ViCareEntity, BinarySensorEntity): entity_description: ViCareBinarySensorEntityDescription def __init__( - self, name, api, device_config, description: ViCareBinarySensorEntityDescription + self, + api: PyViCareDevice, + device_config: PyViCareDeviceConfig, + description: ViCareBinarySensorEntityDescription, ) -> None: """Initialize the sensor.""" - super().__init__(device_config) + super().__init__(device_config, api, description.key) self.entity_description = description - self._attr_name = name - self._api = api - self._device_config = device_config @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._attr_is_on is not None - @property - def unique_id(self) -> str: - """Return unique ID for this device.""" - tmp_id = ( - f"{self._device_config.getConfig().serial}-{self.entity_description.key}" - ) - if hasattr(self._api, "id"): - return f"{tmp_id}-{self._api.id}" - return tmp_id - - def update(self): + def update(self) -> None: """Update state of sensor.""" try: with suppress(PyViCareNotSupportedFeatureError): diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index 2516446a94e..374d98b3397 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -5,6 +5,7 @@ from contextlib import suppress from dataclasses import dataclass import logging +from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from PyViCare.PyViCareUtils import ( PyViCareInvalidDataError, @@ -26,8 +27,6 @@ from .utils import is_supported _LOGGER = logging.getLogger(__name__) -BUTTON_DHW_ACTIVATE_ONETIME_CHARGE = "activate_onetimecharge" - @dataclass class ViCareButtonEntityDescription( @@ -38,8 +37,8 @@ class ViCareButtonEntityDescription( BUTTON_DESCRIPTIONS: tuple[ViCareButtonEntityDescription, ...] = ( ViCareButtonEntityDescription( - key=BUTTON_DHW_ACTIVATE_ONETIME_CHARGE, - name="Activate one-time charge", + key="activate_onetimecharge", + translation_key="activate_onetimecharge", icon="mdi:shower-head", entity_category=EntityCategory.CONFIG, value_getter=lambda api: api.getOneTimeCharge(), @@ -48,22 +47,21 @@ BUTTON_DESCRIPTIONS: tuple[ViCareButtonEntityDescription, ...] = ( ) -def _build_entity( - name: str, - vicare_api, +def _build_entities( + api: PyViCareDevice, device_config: PyViCareDeviceConfig, - entity_description: ViCareButtonEntityDescription, -): - """Create a ViCare button entity.""" - _LOGGER.debug("Found device %s", name) - if is_supported(name, entity_description, vicare_api): - return ViCareButton( - name, - vicare_api, +) -> list[ViCareButton]: + """Create ViCare button entities for a device.""" + + return [ + ViCareButton( + api, device_config, - entity_description, + description, ) - return None + for description in BUTTON_DESCRIPTIONS + if is_supported(description.key, description, api) + ] async def async_setup_entry( @@ -73,21 +71,15 @@ async def async_setup_entry( ) -> None: """Create the ViCare button entities.""" api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] + device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] - entities = [] - - for description in BUTTON_DESCRIPTIONS: - entity = await hass.async_add_executor_job( - _build_entity, - description.name, + async_add_entities( + await hass.async_add_executor_job( + _build_entities, api, - hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], - description, + device_config, ) - if entity is not None: - entities.append(entity) - - async_add_entities(entities) + ) class ViCareButton(ViCareEntity, ButtonEntity): @@ -96,13 +88,14 @@ class ViCareButton(ViCareEntity, ButtonEntity): entity_description: ViCareButtonEntityDescription def __init__( - self, name, api, device_config, description: ViCareButtonEntityDescription + self, + api: PyViCareDevice, + device_config: PyViCareDeviceConfig, + description: ViCareButtonEntityDescription, ) -> None: """Initialize the button.""" - super().__init__(device_config) + super().__init__(device_config, api, description.key) self.entity_description = description - self._device_config = device_config - self._api = api def press(self) -> None: """Handle the button press.""" @@ -117,13 +110,3 @@ class ViCareButton(ViCareEntity, ButtonEntity): _LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception) except PyViCareInvalidDataError as invalid_data_exception: _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) - - @property - def unique_id(self) -> str: - """Return unique ID for this device.""" - tmp_id = ( - f"{self._device_config.getConfig().serial}-{self.entity_description.key}" - ) - if hasattr(self._api, "id"): - return f"{tmp_id}-{self._api.id}" - return tmp_id diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index d306cc6604d..c14f940ffe6 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -5,6 +5,9 @@ from contextlib import suppress import logging from typing import Any +from PyViCare.PyViCareDevice import Device as PyViCareDevice +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from PyViCare.PyViCareHeatingDevice import HeatingCircuit as PyViCareHeatingCircuit from PyViCare.PyViCareUtils import ( PyViCareCommandError, PyViCareInvalidDataError, @@ -31,12 +34,14 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG from .entity import ViCareEntity +from .utils import get_burners, get_circuits, get_compressors _LOGGER = logging.getLogger(__name__) @@ -90,13 +95,20 @@ HA_TO_VICARE_PRESET_HEATING = { } -def _get_circuits(vicare_api): - """Return the list of circuits.""" - try: - return vicare_api.circuits - except PyViCareNotSupportedFeatureError: - _LOGGER.info("No circuits found") - return [] +def _build_entities( + api: PyViCareDevice, + device_config: PyViCareDeviceConfig, +) -> list[ViCareClimate]: + """Create ViCare climate entities for a device.""" + return [ + ViCareClimate( + api, + circuit, + device_config, + "heating", + ) + for circuit in get_circuits(api) + ] async def async_setup_entry( @@ -105,22 +117,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ViCare climate platform.""" - entities = [] api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] - circuits = await hass.async_add_executor_job(_get_circuits, api) - - for circuit in circuits: - suffix = "" - if len(circuits) > 1: - suffix = f" {circuit.id}" - - entity = ViCareClimate( - f"Heating{suffix}", - api, - circuit, - hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], - ) - entities.append(entity) + device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] platform = entity_platform.async_get_current_platform() @@ -130,7 +128,13 @@ async def async_setup_entry( "set_vicare_mode", ) - async_add_entities(entities) + async_add_entities( + await hass.async_add_executor_job( + _build_entities, + api, + device_config, + ) + ) class ViCareClimate(ViCareEntity, ClimateEntity): @@ -148,15 +152,19 @@ class ViCareClimate(ViCareEntity, ClimateEntity): _current_action: bool | None = None _current_mode: str | None = None - def __init__(self, name, api, circuit, device_config) -> None: + def __init__( + self, + api: PyViCareDevice, + circuit: PyViCareHeatingCircuit, + device_config: PyViCareDeviceConfig, + translation_key: str, + ) -> None: """Initialize the climate device.""" - super().__init__(device_config) - self._attr_name = name - self._api = api + super().__init__(device_config, api, circuit.id) self._circuit = circuit self._attributes: dict[str, Any] = {} self._current_program = None - self._attr_unique_id = f"{device_config.getConfig().serial}-{circuit.id}" + self._attr_translation_key = translation_key def update(self) -> None: """Let HA know there has been an update from the ViCare API.""" @@ -209,11 +217,11 @@ class ViCareClimate(ViCareEntity, ClimateEntity): self._current_action = False # Update the specific device attributes with suppress(PyViCareNotSupportedFeatureError): - for burner in self._api.burners: + for burner in get_burners(self._api): self._current_action = self._current_action or burner.getActive() with suppress(PyViCareNotSupportedFeatureError): - for compressor in self._api.compressors: + for compressor in get_compressors(self._api): self._current_action = ( self._current_action or compressor.getActive() ) @@ -292,22 +300,45 @@ class ViCareClimate(ViCareEntity, ClimateEntity): def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode and deactivate any existing programs.""" - vicare_program = HA_TO_VICARE_PRESET_HEATING.get(preset_mode) - if vicare_program is None: - raise ValueError( - f"Cannot set invalid vicare program: {preset_mode}/{vicare_program}" + target_program = HA_TO_VICARE_PRESET_HEATING.get(preset_mode) + if target_program is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="program_unknown", + translation_placeholders={ + "preset": preset_mode, + }, ) - _LOGGER.debug("Setting preset to %s / %s", preset_mode, vicare_program) - if self._current_program != VICARE_PROGRAM_NORMAL: + _LOGGER.debug("Current preset %s", self._current_program) + if self._current_program and self._current_program != VICARE_PROGRAM_NORMAL: # We can't deactivate "normal" + _LOGGER.debug("deactivating %s", self._current_program) try: self._circuit.deactivateProgram(self._current_program) - except PyViCareCommandError: - _LOGGER.debug("Unable to deactivate program %s", self._current_program) - if vicare_program != VICARE_PROGRAM_NORMAL: - # And we can't explicitly activate normal, either - self._circuit.activateProgram(vicare_program) + except PyViCareCommandError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="program_not_deactivated", + translation_placeholders={ + "program": self._current_program, + }, + ) from err + + _LOGGER.debug("Setting preset to %s / %s", preset_mode, target_program) + if target_program != VICARE_PROGRAM_NORMAL: + # And we can't explicitly activate "normal", either + _LOGGER.debug("activating %s", target_program) + try: + self._circuit.activateProgram(target_program) + except PyViCareCommandError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="program_not_activated", + translation_placeholders={ + "program": target_program, + }, + ) from err @property def extra_state_attributes(self): diff --git a/homeassistant/components/vicare/config_flow.py b/homeassistant/components/vicare/config_flow.py index 5b2d3afa427..87bfcf7b146 100644 --- a/homeassistant/components/vicare/config_flow.py +++ b/homeassistant/components/vicare/config_flow.py @@ -1,6 +1,7 @@ """Config flow for ViCare integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -28,11 +29,28 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +REAUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_CLIENT_ID): cv.string, + } +) + +USER_SCHEMA = REAUTH_SCHEMA.extend( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_HEATING_TYPE, default=DEFAULT_HEATING_TYPE.value): vol.In( + [e.value for e in HeatingType] + ), + } +) + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for ViCare.""" VERSION = 1 + entry: config_entries.ConfigEntry | None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -41,14 +59,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") - data_schema = { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_HEATING_TYPE, default=DEFAULT_HEATING_TYPE.value): vol.In( - [e.value for e in HeatingType] - ), - } errors: dict[str, str] = {} if user_input is not None: @@ -63,7 +73,45 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", - data_schema=vol.Schema(data_schema), + data_schema=USER_SCHEMA, + errors=errors, + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle re-authentication with ViCare.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm re-authentication with ViCare.""" + errors: dict[str, str] = {} + assert self.entry is not None + + if user_input: + data = { + **self.entry.data, + **user_input, + } + + try: + await self.hass.async_add_executor_job(vicare_login, self.hass, data) + except (PyViCareInvalidConfigurationError, PyViCareInvalidCredentialsError): + errors["base"] = "invalid_auth" + else: + self.hass.config_entries.async_update_entry( + self.entry, + data=data, + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + REAUTH_SCHEMA, self.entry.data + ), errors=errors, ) diff --git a/homeassistant/components/vicare/const.py b/homeassistant/components/vicare/const.py index 546f18985e8..3ed81ab587a 100644 --- a/homeassistant/components/vicare/const.py +++ b/homeassistant/components/vicare/const.py @@ -6,10 +6,11 @@ from homeassistant.const import Platform, UnitOfEnergy, UnitOfVolume DOMAIN = "vicare" PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, + Platform.NUMBER, Platform.SENSOR, - Platform.BINARY_SENSOR, Platform.WATER_HEATER, ] diff --git a/homeassistant/components/vicare/entity.py b/homeassistant/components/vicare/entity.py index 089f9c062b8..af35c7bf8dd 100644 --- a/homeassistant/components/vicare/entity.py +++ b/homeassistant/components/vicare/entity.py @@ -1,4 +1,7 @@ """Entities for the ViCare integration.""" +from PyViCare.PyViCareDevice import Device as PyViCareDevice +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig + from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -10,8 +13,19 @@ class ViCareEntity(Entity): _attr_has_entity_name = True - def __init__(self, device_config) -> None: + def __init__( + self, + device_config: PyViCareDeviceConfig, + device: PyViCareDevice, + unique_id_suffix: str, + ) -> None: """Initialize the entity.""" + self._api = device + + self._attr_unique_id = f"{device_config.getConfig().serial}-{unique_id_suffix}" + # valid for compressors, circuits, burners (HeatingDeviceWithComponent) + if hasattr(device, "id"): + self._attr_unique_id += f"-{device.id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device_config.getConfig().serial)}, diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index e8bc4178073..cbde6242082 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -1,7 +1,7 @@ { "domain": "vicare", "name": "Viessmann ViCare", - "codeowners": [], + "codeowners": ["@CFenner"], "config_flow": true, "dhcp": [ { @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.28.1"] + "requirements": ["PyViCare==2.29.0"] } diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py new file mode 100644 index 00000000000..5511f2a5294 --- /dev/null +++ b/homeassistant/components/vicare/number.py @@ -0,0 +1,180 @@ +"""Number for ViCare.""" +from __future__ import annotations + +from collections.abc import Callable +from contextlib import suppress +from dataclasses import dataclass +import logging +from typing import Any + +from PyViCare.PyViCareDevice import Device as PyViCareDevice +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from PyViCare.PyViCareHeatingDevice import ( + HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent, +) +from PyViCare.PyViCareUtils import ( + PyViCareInvalidDataError, + PyViCareNotSupportedFeatureError, + PyViCareRateLimitError, +) +from requests.exceptions import ConnectionError as RequestConnectionError + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ViCareRequiredKeysMixin +from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG +from .entity import ViCareEntity +from .utils import get_circuits, is_supported + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class ViCareNumberEntityDescription(NumberEntityDescription, ViCareRequiredKeysMixin): + """Describes ViCare number entity.""" + + value_setter: Callable[[PyViCareDevice, float], Any] | None = None + min_value_getter: Callable[[PyViCareDevice], float | None] | None = None + max_value_getter: Callable[[PyViCareDevice], float | None] | None = None + stepping_getter: Callable[[PyViCareDevice], float | None] | None = None + + +CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( + ViCareNumberEntityDescription( + key="heating curve shift", + translation_key="heating_curve_shift", + icon="mdi:plus-minus-variant", + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getHeatingCurveShift(), + value_setter=lambda api, shift: ( + api.setHeatingCurve(shift, api.getHeatingCurveSlope()) + ), + min_value_getter=lambda api: api.getHeatingCurveShiftMin(), + max_value_getter=lambda api: api.getHeatingCurveShiftMax(), + stepping_getter=lambda api: api.getHeatingCurveShiftStepping(), + native_min_value=-13, + native_max_value=40, + native_step=1, + ), + ViCareNumberEntityDescription( + key="heating curve slope", + translation_key="heating_curve_slope", + icon="mdi:slope-uphill", + entity_category=EntityCategory.CONFIG, + value_getter=lambda api: api.getHeatingCurveSlope(), + value_setter=lambda api, slope: ( + api.setHeatingCurve(api.getHeatingCurveShift(), slope) + ), + min_value_getter=lambda api: api.getHeatingCurveSlopeMin(), + max_value_getter=lambda api: api.getHeatingCurveSlopeMax(), + stepping_getter=lambda api: api.getHeatingCurveSlopeStepping(), + native_min_value=0.2, + native_max_value=3.5, + native_step=0.1, + ), +) + + +def _build_entities( + api: PyViCareDevice, + device_config: PyViCareDeviceConfig, +) -> list[ViCareNumber]: + """Create ViCare number entities for a component.""" + + return [ + ViCareNumber( + circuit, + device_config, + description, + ) + for circuit in get_circuits(api) + for description in CIRCUIT_ENTITY_DESCRIPTIONS + if is_supported(description.key, description, circuit) + ] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Create the ViCare number devices.""" + api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] + device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] + + async_add_entities( + await hass.async_add_executor_job( + _build_entities, + api, + device_config, + ) + ) + + +class ViCareNumber(ViCareEntity, NumberEntity): + """Representation of a ViCare number.""" + + entity_description: ViCareNumberEntityDescription + + def __init__( + self, + api: PyViCareHeatingDeviceComponent, + device_config: PyViCareDeviceConfig, + description: ViCareNumberEntityDescription, + ) -> None: + """Initialize the number.""" + super().__init__(device_config, api, description.key) + self.entity_description = description + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._attr_native_value is not None + + def set_native_value(self, value: float) -> None: + """Set new value.""" + if self.entity_description.value_setter: + self.entity_description.value_setter(self._api, value) + self.schedule_update_ha_state() + + def update(self) -> None: + """Update state of number.""" + try: + with suppress(PyViCareNotSupportedFeatureError): + self._attr_native_value = self.entity_description.value_getter( + self._api + ) + if min_value := _get_value( + self.entity_description.min_value_getter, self._api + ): + self._attr_native_min_value = min_value + + if max_value := _get_value( + self.entity_description.max_value_getter, self._api + ): + self._attr_native_max_value = max_value + + if stepping_value := _get_value( + self.entity_description.stepping_getter, self._api + ): + self._attr_native_step = stepping_value + except RequestConnectionError: + _LOGGER.error("Unable to retrieve data from ViCare server") + except ValueError: + _LOGGER.error("Unable to decode data from ViCare server") + except PyViCareRateLimitError as limit_exception: + _LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception) + except PyViCareInvalidDataError as invalid_data_exception: + _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) + + +def _get_value( + fn: Callable[[PyViCareDevice], float | None] | None, + api: PyViCareHeatingDeviceComponent, +) -> float | None: + return None if fn is None else fn(api) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 325f3bf2d07..875d8790c52 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -6,8 +6,11 @@ from contextlib import suppress from dataclasses import dataclass import logging -from PyViCare.PyViCareDevice import Device +from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from PyViCare.PyViCareHeatingDevice import ( + HeatingDeviceWithComponent as PyViCareHeatingDeviceWithComponent, +) from PyViCare.PyViCareUtils import ( PyViCareInvalidDataError, PyViCareNotSupportedFeatureError, @@ -24,11 +27,13 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, + EntityCategory, UnitOfEnergy, UnitOfPower, UnitOfTemperature, UnitOfTime, UnitOfVolume, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -43,7 +48,7 @@ from .const import ( VICARE_UNIT_TO_UNIT_OF_MEASUREMENT, ) from .entity import ViCareEntity -from .utils import is_supported +from .utils import get_burners, get_circuits, get_compressors, is_supported _LOGGER = logging.getLogger(__name__) @@ -57,13 +62,13 @@ VICARE_UNIT_TO_DEVICE_CLASS = { class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysMixin): """Describes ViCare sensor entity.""" - unit_getter: Callable[[Device], str | None] | None = None + unit_getter: Callable[[PyViCareDevice], str | None] | None = None GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ViCareSensorEntityDescription( key="outside_temperature", - name="Outside Temperature", + translation_key="outside_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getOutsideTemperature(), device_class=SensorDeviceClass.TEMPERATURE, @@ -71,7 +76,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="return_temperature", - name="Return Temperature", + translation_key="return_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getReturnTemperature(), device_class=SensorDeviceClass.TEMPERATURE, @@ -79,7 +84,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="boiler_temperature", - name="Boiler Temperature", + translation_key="boiler_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getBoilerTemperature(), device_class=SensorDeviceClass.TEMPERATURE, @@ -87,7 +92,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="boiler_supply_temperature", - name="Boiler Supply Temperature", + translation_key="boiler_supply_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getBoilerCommonSupplyTemperature(), device_class=SensorDeviceClass.TEMPERATURE, @@ -95,7 +100,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="primary_circuit_supply_temperature", - name="Primary Circuit Supply Temperature", + translation_key="primary_circuit_supply_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getSupplyTemperaturePrimaryCircuit(), device_class=SensorDeviceClass.TEMPERATURE, @@ -103,7 +108,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="primary_circuit_return_temperature", - name="Primary Circuit Return Temperature", + translation_key="primary_circuit_return_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getReturnTemperaturePrimaryCircuit(), device_class=SensorDeviceClass.TEMPERATURE, @@ -111,7 +116,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="secondary_circuit_supply_temperature", - name="Secondary Circuit Supply Temperature", + translation_key="secondary_circuit_supply_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getSupplyTemperatureSecondaryCircuit(), device_class=SensorDeviceClass.TEMPERATURE, @@ -119,7 +124,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="secondary_circuit_return_temperature", - name="Secondary Circuit Return Temperature", + translation_key="secondary_circuit_return_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getReturnTemperatureSecondaryCircuit(), device_class=SensorDeviceClass.TEMPERATURE, @@ -127,7 +132,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="hotwater_out_temperature", - name="Hot Water Out Temperature", + translation_key="hotwater_out_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getDomesticHotWaterOutletTemperature(), device_class=SensorDeviceClass.TEMPERATURE, @@ -135,7 +140,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="hotwater_max_temperature", - name="Hot Water Max Temperature", + translation_key="hotwater_max_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getDomesticHotWaterMaxTemperature(), device_class=SensorDeviceClass.TEMPERATURE, @@ -143,7 +148,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="hotwater_min_temperature", - name="Hot Water Min Temperature", + translation_key="hotwater_min_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getDomesticHotWaterMinTemperature(), device_class=SensorDeviceClass.TEMPERATURE, @@ -151,63 +156,63 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="hotwater_gas_consumption_today", - name="Hot water gas consumption today", + translation_key="hotwater_gas_consumption_today", value_getter=lambda api: api.getGasConsumptionDomesticHotWaterToday(), unit_getter=lambda api: api.getGasConsumptionDomesticHotWaterUnit(), state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( key="hotwater_gas_consumption_heating_this_week", - name="Hot water gas consumption this week", + translation_key="hotwater_gas_consumption_heating_this_week", value_getter=lambda api: api.getGasConsumptionDomesticHotWaterThisWeek(), unit_getter=lambda api: api.getGasConsumptionDomesticHotWaterUnit(), state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( key="hotwater_gas_consumption_heating_this_month", - name="Hot water gas consumption this month", + translation_key="hotwater_gas_consumption_heating_this_month", value_getter=lambda api: api.getGasConsumptionDomesticHotWaterThisMonth(), unit_getter=lambda api: api.getGasConsumptionDomesticHotWaterUnit(), state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( key="hotwater_gas_consumption_heating_this_year", - name="Hot water gas consumption this year", + translation_key="hotwater_gas_consumption_heating_this_year", value_getter=lambda api: api.getGasConsumptionDomesticHotWaterThisYear(), unit_getter=lambda api: api.getGasConsumptionDomesticHotWaterUnit(), state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( key="gas_consumption_heating_today", - name="Heating gas consumption today", + translation_key="gas_consumption_heating_today", value_getter=lambda api: api.getGasConsumptionHeatingToday(), unit_getter=lambda api: api.getGasConsumptionHeatingUnit(), state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( key="gas_consumption_heating_this_week", - name="Heating gas consumption this week", + translation_key="gas_consumption_heating_this_week", value_getter=lambda api: api.getGasConsumptionHeatingThisWeek(), unit_getter=lambda api: api.getGasConsumptionHeatingUnit(), state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( key="gas_consumption_heating_this_month", - name="Heating gas consumption this month", + translation_key="gas_consumption_heating_this_month", value_getter=lambda api: api.getGasConsumptionHeatingThisMonth(), unit_getter=lambda api: api.getGasConsumptionHeatingUnit(), state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( key="gas_consumption_heating_this_year", - name="Heating gas consumption this year", + translation_key="gas_consumption_heating_this_year", value_getter=lambda api: api.getGasConsumptionHeatingThisYear(), unit_getter=lambda api: api.getGasConsumptionHeatingUnit(), state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( key="gas_summary_consumption_heating_currentday", - name="Heating gas consumption current day", + translation_key="gas_summary_consumption_heating_currentday", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, value_getter=lambda api: api.getGasSummaryConsumptionHeatingCurrentDay(), unit_getter=lambda api: api.getGasSummaryConsumptionHeatingUnit(), @@ -215,7 +220,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="gas_summary_consumption_heating_currentmonth", - name="Heating gas consumption current month", + translation_key="gas_summary_consumption_heating_currentmonth", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, value_getter=lambda api: api.getGasSummaryConsumptionHeatingCurrentMonth(), unit_getter=lambda api: api.getGasSummaryConsumptionHeatingUnit(), @@ -223,7 +228,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="gas_summary_consumption_heating_currentyear", - name="Heating gas consumption current year", + translation_key="gas_summary_consumption_heating_currentyear", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, value_getter=lambda api: api.getGasSummaryConsumptionHeatingCurrentYear(), unit_getter=lambda api: api.getGasSummaryConsumptionHeatingUnit(), @@ -231,7 +236,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="gas_summary_consumption_heating_lastsevendays", - name="Heating gas consumption last seven days", + translation_key="gas_summary_consumption_heating_lastsevendays", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, value_getter=lambda api: api.getGasSummaryConsumptionHeatingLastSevenDays(), unit_getter=lambda api: api.getGasSummaryConsumptionHeatingUnit(), @@ -239,7 +244,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="hotwater_gas_summary_consumption_heating_currentday", - name="Hot water gas consumption current day", + translation_key="hotwater_gas_summary_consumption_heating_currentday", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, value_getter=lambda api: api.getGasSummaryConsumptionDomesticHotWaterCurrentDay(), unit_getter=lambda api: api.getGasSummaryConsumptionDomesticHotWaterUnit(), @@ -247,7 +252,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="hotwater_gas_summary_consumption_heating_currentmonth", - name="Hot water gas consumption current month", + translation_key="hotwater_gas_summary_consumption_heating_currentmonth", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, value_getter=lambda api: api.getGasSummaryConsumptionDomesticHotWaterCurrentMonth(), unit_getter=lambda api: api.getGasSummaryConsumptionDomesticHotWaterUnit(), @@ -255,7 +260,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="hotwater_gas_summary_consumption_heating_currentyear", - name="Hot water gas consumption current year", + translation_key="hotwater_gas_summary_consumption_heating_currentyear", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, value_getter=lambda api: api.getGasSummaryConsumptionDomesticHotWaterCurrentYear(), unit_getter=lambda api: api.getGasSummaryConsumptionDomesticHotWaterUnit(), @@ -263,7 +268,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="hotwater_gas_summary_consumption_heating_lastsevendays", - name="Hot water gas consumption last seven days", + translation_key="hotwater_gas_summary_consumption_heating_lastsevendays", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, value_getter=lambda api: api.getGasSummaryConsumptionDomesticHotWaterLastSevenDays(), unit_getter=lambda api: api.getGasSummaryConsumptionDomesticHotWaterUnit(), @@ -271,7 +276,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="energy_summary_consumption_heating_currentday", - name="Energy consumption of gas heating current day", + translation_key="energy_summary_consumption_heating_currentday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerSummaryConsumptionHeatingCurrentDay(), unit_getter=lambda api: api.getPowerSummaryConsumptionHeatingUnit(), @@ -279,7 +284,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="energy_summary_consumption_heating_currentmonth", - name="Energy consumption of gas heating current month", + translation_key="energy_summary_consumption_heating_currentmonth", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerSummaryConsumptionHeatingCurrentMonth(), unit_getter=lambda api: api.getPowerSummaryConsumptionHeatingUnit(), @@ -287,7 +292,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="energy_summary_consumption_heating_currentyear", - name="Energy consumption of gas heating current year", + translation_key="energy_summary_consumption_heating_currentyear", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerSummaryConsumptionHeatingCurrentYear(), unit_getter=lambda api: api.getPowerSummaryConsumptionHeatingUnit(), @@ -295,7 +300,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="energy_summary_consumption_heating_lastsevendays", - name="Energy consumption of gas heating last seven days", + translation_key="energy_summary_consumption_heating_lastsevendays", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerSummaryConsumptionHeatingLastSevenDays(), unit_getter=lambda api: api.getPowerSummaryConsumptionHeatingUnit(), @@ -303,7 +308,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="energy_dhw_summary_consumption_heating_currentday", - name="Energy consumption of hot water gas heating current day", + translation_key="energy_dhw_summary_consumption_heating_currentday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerSummaryConsumptionDomesticHotWaterCurrentDay(), unit_getter=lambda api: api.getPowerSummaryConsumptionDomesticHotWaterUnit(), @@ -311,7 +316,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="energy_dhw_summary_consumption_heating_currentmonth", - name="Energy consumption of hot water gas heating current month", + translation_key="energy_dhw_summary_consumption_heating_currentmonth", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerSummaryConsumptionDomesticHotWaterCurrentMonth(), unit_getter=lambda api: api.getPowerSummaryConsumptionDomesticHotWaterUnit(), @@ -319,7 +324,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="energy_dhw_summary_consumption_heating_currentyear", - name="Energy consumption of hot water gas heating current year", + translation_key="energy_dhw_summary_consumption_heating_currentyear", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerSummaryConsumptionDomesticHotWaterCurrentYear(), unit_getter=lambda api: api.getPowerSummaryConsumptionDomesticHotWaterUnit(), @@ -327,7 +332,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="energy_summary_dhw_consumption_heating_lastsevendays", - name="Energy consumption of hot water gas heating last seven days", + translation_key="energy_summary_dhw_consumption_heating_lastsevendays", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerSummaryConsumptionDomesticHotWaterLastSevenDays(), unit_getter=lambda api: api.getPowerSummaryConsumptionDomesticHotWaterUnit(), @@ -335,7 +340,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="power_production_current", - name="Power production current", + translation_key="power_production_current", native_unit_of_measurement=UnitOfPower.WATT, value_getter=lambda api: api.getPowerProductionCurrent(), device_class=SensorDeviceClass.POWER, @@ -343,7 +348,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="power_production_today", - name="Energy production today", + translation_key="power_production_today", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerProductionToday(), device_class=SensorDeviceClass.ENERGY, @@ -351,7 +356,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="power_production_this_week", - name="Energy production this week", + translation_key="power_production_this_week", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerProductionThisWeek(), device_class=SensorDeviceClass.ENERGY, @@ -359,7 +364,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="power_production_this_month", - name="Energy production this month", + translation_key="power_production_this_month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerProductionThisMonth(), device_class=SensorDeviceClass.ENERGY, @@ -367,7 +372,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="power_production_this_year", - name="Energy production this year", + translation_key="power_production_this_year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerProductionThisYear(), device_class=SensorDeviceClass.ENERGY, @@ -375,7 +380,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="solar storage temperature", - name="Solar Storage Temperature", + translation_key="solar_storage_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getSolarStorageTemperature(), device_class=SensorDeviceClass.TEMPERATURE, @@ -383,7 +388,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="collector temperature", - name="Solar Collector Temperature", + translation_key="collector_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getSolarCollectorTemperature(), device_class=SensorDeviceClass.TEMPERATURE, @@ -391,7 +396,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="solar power production today", - name="Solar energy production today", + translation_key="solar_power_production_today", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getSolarPowerProductionToday(), unit_getter=lambda api: api.getSolarPowerProductionUnit(), @@ -400,7 +405,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="solar power production this week", - name="Solar energy production this week", + translation_key="solar_power_production_this_week", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getSolarPowerProductionThisWeek(), unit_getter=lambda api: api.getSolarPowerProductionUnit(), @@ -409,7 +414,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="solar power production this month", - name="Solar energy production this month", + translation_key="solar_power_production_this_month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getSolarPowerProductionThisMonth(), unit_getter=lambda api: api.getSolarPowerProductionUnit(), @@ -418,7 +423,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="solar power production this year", - name="Solar energy production this year", + translation_key="solar_power_production_this_year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getSolarPowerProductionThisYear(), unit_getter=lambda api: api.getSolarPowerProductionUnit(), @@ -427,7 +432,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="power consumption today", - name="Energy consumption today", + translation_key="power_consumption_today", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerConsumptionToday(), unit_getter=lambda api: api.getPowerConsumptionUnit(), @@ -436,7 +441,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="power consumption this week", - name="Power consumption this week", + translation_key="power_consumption_this_week", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerConsumptionThisWeek(), unit_getter=lambda api: api.getPowerConsumptionUnit(), @@ -445,7 +450,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="power consumption this month", - name="Energy consumption this month", + translation_key="power consumption this month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerConsumptionThisMonth(), unit_getter=lambda api: api.getPowerConsumptionUnit(), @@ -454,7 +459,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="power consumption this year", - name="Energy consumption this year", + translation_key="power_consumption_this_year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_getter=lambda api: api.getPowerConsumptionThisYear(), unit_getter=lambda api: api.getPowerConsumptionUnit(), @@ -463,7 +468,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="buffer top temperature", - name="Buffer top temperature", + translation_key="buffer_top_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getBufferTopTemperature(), device_class=SensorDeviceClass.TEMPERATURE, @@ -471,18 +476,27 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="buffer main temperature", - name="Buffer main temperature", + translation_key="buffer_main_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getBufferMainTemperature(), device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), + ViCareSensorEntityDescription( + key="volumetric_flow", + translation_key="volumetric_flow", + icon="mdi:gauge", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + value_getter=lambda api: api.getVolumetricFlowReturn() / 1000, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), ) CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ViCareSensorEntityDescription( key="supply_temperature", - name="Supply Temperature", + translation_key="supply_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getSupplyTemperature(), device_class=SensorDeviceClass.TEMPERATURE, @@ -493,14 +507,14 @@ CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( BURNER_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ViCareSensorEntityDescription( key="burner_starts", - name="Burner Starts", + translation_key="burner_starts", icon="mdi:counter", value_getter=lambda api: api.getStarts(), state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( key="burner_hours", - name="Burner Hours", + translation_key="burner_hours", icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHours(), @@ -508,7 +522,7 @@ BURNER_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="burner_modulation", - name="Burner Modulation", + translation_key="burner_modulation", icon="mdi:percent", native_unit_of_measurement=PERCENTAGE, value_getter=lambda api: api.getModulation(), @@ -519,14 +533,14 @@ BURNER_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ViCareSensorEntityDescription( key="compressor_starts", - name="Compressor Starts", + translation_key="compressor_starts", icon="mdi:counter", value_getter=lambda api: api.getStarts(), state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( key="compressor_hours", - name="Compressor Hours", + translation_key="compressor_hours", icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHours(), @@ -534,7 +548,7 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="compressor_hours_loadclass1", - name="Compressor Hours Load Class 1", + translation_key="compressor_hours_loadclass1", icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHoursLoadClass1(), @@ -542,7 +556,7 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="compressor_hours_loadclass2", - name="Compressor Hours Load Class 2", + translation_key="compressor_hours_loadclass2", icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHoursLoadClass2(), @@ -550,7 +564,7 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="compressor_hours_loadclass3", - name="Compressor Hours Load Class 3", + translation_key="compressor_hours_loadclass3", icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHoursLoadClass3(), @@ -558,7 +572,7 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="compressor_hours_loadclass4", - name="Compressor Hours Load Class 4", + translation_key="compressor_hours_loadclass4", icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHoursLoadClass4(), @@ -566,26 +580,30 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ViCareSensorEntityDescription( key="compressor_hours_loadclass5", - name="Compressor Hours Load Class 5", + translation_key="compressor_hours_loadclass5", icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHoursLoadClass5(), state_class=SensorStateClass.TOTAL_INCREASING, ), + ViCareSensorEntityDescription( + key="compressor_phase", + translation_key="compressor_phase", + icon="mdi:information", + value_getter=lambda api: api.getPhase(), + entity_category=EntityCategory.DIAGNOSTIC, + ), ) def _build_entity( - name: str, vicare_api, device_config: PyViCareDeviceConfig, entity_description: ViCareSensorEntityDescription, ): """Create a ViCare sensor entity.""" - _LOGGER.debug("Found device %s", name) - if is_supported(name, entity_description, vicare_api): + if is_supported(entity_description.key, entity_description, vicare_api): return ViCareSensor( - name, vicare_api, device_config, entity_description, @@ -603,62 +621,95 @@ async def _entities_from_descriptions( """Create entities from descriptions and list of burners/circuits.""" for description in sensor_descriptions: for current in iterables: - suffix = "" - if len(iterables) > 1: - suffix = f" {current.id}" entity = await hass.async_add_executor_job( _build_entity, - f"{description.name}{suffix}", current, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, ) - if entity is not None: + if entity: entities.append(entity) +def _build_entities( + device: PyViCareDevice, + device_config: PyViCareDeviceConfig, +) -> list[ViCareSensor]: + """Create ViCare sensor entities for a device.""" + + entities: list[ViCareSensor] = _build_entities_for_device(device, device_config) + entities.extend( + _build_entities_for_component( + get_circuits(device), device_config, CIRCUIT_SENSORS + ) + ) + entities.extend( + _build_entities_for_component( + get_burners(device), device_config, BURNER_SENSORS + ) + ) + entities.extend( + _build_entities_for_component( + get_compressors(device), device_config, COMPRESSOR_SENSORS + ) + ) + return entities + + +def _build_entities_for_device( + device: PyViCareDevice, + device_config: PyViCareDeviceConfig, +) -> list[ViCareSensor]: + """Create device specific ViCare sensor entities.""" + + return [ + ViCareSensor( + device, + device_config, + description, + ) + for description in GLOBAL_SENSORS + if is_supported(description.key, description, device) + ] + + +def _build_entities_for_component( + components: list[PyViCareHeatingDeviceWithComponent], + device_config: PyViCareDeviceConfig, + entity_descriptions: tuple[ViCareSensorEntityDescription, ...], +) -> list[ViCareSensor]: + """Create component specific ViCare sensor entities.""" + + return [ + ViCareSensor( + component, + device_config, + description, + ) + for component in components + for description in entity_descriptions + if is_supported(description.key, description, component) + ] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare sensor devices.""" - api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] + api: PyViCareDevice = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] + device_config: PyViCareDeviceConfig = hass.data[DOMAIN][config_entry.entry_id][ + VICARE_DEVICE_CONFIG + ] - entities = [] - for description in GLOBAL_SENSORS: - entity = await hass.async_add_executor_job( - _build_entity, - description.name, + async_add_entities( + await hass.async_add_executor_job( + _build_entities, api, - hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], - description, + device_config, ) - if entity is not None: - entities.append(entity) - - try: - await _entities_from_descriptions( - hass, entities, CIRCUIT_SENSORS, api.circuits, config_entry - ) - except PyViCareNotSupportedFeatureError: - _LOGGER.info("No circuits found") - - try: - await _entities_from_descriptions( - hass, entities, BURNER_SENSORS, api.burners, config_entry - ) - except PyViCareNotSupportedFeatureError: - _LOGGER.info("No burners found") - - try: - await _entities_from_descriptions( - hass, entities, COMPRESSOR_SENSORS, api.compressors, config_entry - ) - except PyViCareNotSupportedFeatureError: - _LOGGER.info("No compressors found") - - async_add_entities(entities) + ) class ViCareSensor(ViCareEntity, SensorEntity): @@ -667,31 +718,21 @@ class ViCareSensor(ViCareEntity, SensorEntity): entity_description: ViCareSensorEntityDescription def __init__( - self, name, api, device_config, description: ViCareSensorEntityDescription + self, + api, + device_config: PyViCareDeviceConfig, + description: ViCareSensorEntityDescription, ) -> None: """Initialize the sensor.""" - super().__init__(device_config) + super().__init__(device_config, api, description.key) self.entity_description = description - self._attr_name = name - self._api = api - self._device_config = device_config @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._attr_native_value is not None - @property - def unique_id(self) -> str: - """Return unique ID for this device.""" - tmp_id = ( - f"{self._device_config.getConfig().serial}-{self.entity_description.key}" - ) - if hasattr(self._api, "id"): - return f"{tmp_id}-{self._api.id}" - return tmp_id - - def update(self): + def update(self) -> None: """Update state of sensor.""" try: with suppress(PyViCareNotSupportedFeatureError): diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 056a4df7920..47ee60b2ea8 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -10,6 +10,13 @@ "client_id": "Client ID", "heating_type": "Heating type" } + }, + "reauth_confirm": { + "description": "Please verify credentials.", + "data": { + "password": "[%key:common::config_flow::data::password%]", + "client_id": "[%key:component::vicare::config::step::user::data::client_id%]" + } } }, "error": { @@ -17,9 +24,281 @@ }, "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" } }, + "entity": { + "binary_sensor": { + "circulation_pump": { + "name": "Circulation pump" + }, + "frost_protection": { + "name": "Frost protection" + }, + "burner": { + "name": "Burner" + }, + "compressor": { + "name": "Compressor" + }, + "solar_pump": { + "name": "Solar pump" + }, + "domestic_hot_water_charging": { + "name": "DHW charging" + }, + "domestic_hot_water_circulation_pump": { + "name": "DHW circulation pump" + }, + "domestic_hot_water_pump": { + "name": "DHW pump" + } + }, + "button": { + "activate_onetimecharge": { + "name": "Activate one-time charge" + } + }, + "climate": { + "heating": { + "name": "Heating" + } + }, + "number": { + "heating_curve_shift": { + "name": "Heating curve shift" + }, + "heating_curve_slope": { + "name": "Heating curve slope" + }, + "normal_temperature": { + "name": "Normal temperature" + }, + "reduced_temperature": { + "name": "Reduced temperature" + }, + "comfort_temperature": { + "name": "Comfort temperature" + }, + "eco_temperature": { + "name": "Eco temperature" + } + }, + "sensor": { + "outside_temperature": { + "name": "Outside temperature" + }, + "return_temperature": { + "name": "Return temperature" + }, + "boiler_temperature": { + "name": "Boiler temperature" + }, + "boiler_supply_temperature": { + "name": "Boiler supply temperature" + }, + "primary_circuit_supply_temperature": { + "name": "Primary circuit supply temperature" + }, + "primary_circuit_return_temperature": { + "name": "Primary circuit return temperature" + }, + "secondary_circuit_supply_temperature": { + "name": "Secondary circuit supply temperature" + }, + "secondary_circuit_return_temperature": { + "name": "Secondary circuit return temperature" + }, + "hotwater_out_temperature": { + "name": "DHW out temperature" + }, + "hotwater_max_temperature": { + "name": "DHW max temperature" + }, + "hotwater_min_temperature": { + "name": "DHW min temperature" + }, + "hotwater_gas_consumption_today": { + "name": "DHW gas consumption today" + }, + "hotwater_gas_consumption_heating_this_week": { + "name": "DHW gas consumption this week" + }, + "hotwater_gas_consumption_heating_this_month": { + "name": "DHW gas consumption this month" + }, + "hotwater_gas_consumption_heating_this_year": { + "name": "DHW gas consumption this year" + }, + "gas_consumption_heating_today": { + "name": "Heating gas consumption today" + }, + "gas_consumption_heating_this_week": { + "name": "Heating gas consumption this week" + }, + "gas_consumption_heating_this_month": { + "name": "Heating gas consumption this month" + }, + "gas_consumption_heating_this_year": { + "name": "Heating gas consumption this year" + }, + "gas_summary_consumption_heating_currentday": { + "name": "Heating gas consumption current day" + }, + "gas_summary_consumption_heating_currentmonth": { + "name": "Heating gas consumption current month" + }, + "gas_summary_consumption_heating_currentyear": { + "name": "Heating gas consumption current year" + }, + "gas_summary_consumption_heating_lastsevendays": { + "name": "Heating gas consumption last seven days" + }, + "hotwater_gas_summary_consumption_heating_currentday": { + "name": "DHW gas consumption current day" + }, + "hotwater_gas_summary_consumption_heating_currentmonth": { + "name": "DHW gas consumption current month" + }, + "hotwater_gas_summary_consumption_heating_currentyear": { + "name": "DHW gas consumption current year" + }, + "hotwater_gas_summary_consumption_heating_lastsevendays": { + "name": "DHW gas consumption last seven days" + }, + "energy_summary_consumption_heating_currentday": { + "name": "Energy consumption of gas heating current day" + }, + "energy_summary_consumption_heating_currentmonth": { + "name": "Energy consumption of gas heating current month" + }, + "energy_summary_consumption_heating_currentyear": { + "name": "Energy consumption of gas heating current year" + }, + "energy_summary_consumption_heating_lastsevendays": { + "name": "Energy consumption of gas heating last seven days" + }, + "energy_dhw_summary_consumption_heating_currentday": { + "name": "Energy consumption of hot water gas heating current day" + }, + "energy_dhw_summary_consumption_heating_currentmonth": { + "name": "Energy consumption of hot water gas heating current month" + }, + "energy_dhw_summary_consumption_heating_currentyear": { + "name": "Energy consumption of hot water gas heating current year" + }, + "energy_summary_dhw_consumption_heating_lastsevendays": { + "name": "Energy consumption of hot water gas heating last seven days" + }, + "power_production_current": { + "name": "Power production current" + }, + "power_production_today": { + "name": "Energy production today" + }, + "power_production_this_week": { + "name": "Energy production this week" + }, + "power_production_this_month": { + "name": "Energy production this month" + }, + "power_production_this_year": { + "name": "Energy production this year" + }, + "solar_storage_temperature": { + "name": "Solar storage temperature" + }, + "collector_temperature": { + "name": "Solar collector temperature" + }, + "solar_power_production_today": { + "name": "Solar energy production today" + }, + "solar_power_production_this_week": { + "name": "Solar energy production this week" + }, + "solar_power_production_this_month": { + "name": "Solar energy production this month" + }, + "solar_power_production_this_year": { + "name": "Solar energy production this year" + }, + "power_consumption_today": { + "name": "Energy consumption today" + }, + "power_consumption_this_week": { + "name": "Power consumption this week" + }, + "power_consumption_this_month": { + "name": "Energy consumption this month" + }, + "power_consumption_this_year": { + "name": "Energy consumption this year" + }, + "buffer_top_temperature": { + "name": "Buffer top temperature" + }, + "buffer_main_temperature": { + "name": "Buffer main temperature" + }, + "volumetric_flow": { + "name": "Volumetric flow" + }, + "supply_temperature": { + "name": "Supply temperature" + }, + "burner_starts": { + "name": "Burner starts" + }, + "burner_hours": { + "name": "Burner hours" + }, + "burner_modulation": { + "name": "Burner modulation" + }, + "compressor_starts": { + "name": "Compressor starts" + }, + "compressor_hours": { + "name": "Compressor hours" + }, + "compressor_hours_loadclass1": { + "name": "Compressor hours load class 1" + }, + "compressor_hours_loadclass2": { + "name": "Compressor hours load class 2" + }, + "compressor_hours_loadclass3": { + "name": "Compressor hours load class 3" + }, + "compressor_hours_loadclass4": { + "name": "Compressor hours load class 4" + }, + "compressor_hours_loadclass5": { + "name": "Compressor hours load class 5" + }, + "compressor_phase": { + "name": "Compressor phase" + } + }, + "water_heater": { + "domestic_hot_water": { + "name": "Domestic hot water" + } + } + }, + "exceptions": { + "program_unknown": { + "message": "Cannot translate preset {preset} into a valid ViCare program" + }, + "program_not_activated": { + "message": "Unable to activate ViCare program {program}" + }, + "program_not_deactivated": { + "message": "Unable to deactivate ViCare program {program}" + } + }, "services": { "set_vicare_mode": { "name": "Set ViCare mode", diff --git a/homeassistant/components/vicare/utils.py b/homeassistant/components/vicare/utils.py index 19a75c00962..5b3fb38337f 100644 --- a/homeassistant/components/vicare/utils.py +++ b/homeassistant/components/vicare/utils.py @@ -1,6 +1,10 @@ """ViCare helpers functions.""" import logging +from PyViCare.PyViCareDevice import Device as PyViCareDevice +from PyViCare.PyViCareHeatingDevice import ( + HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent, +) from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError from . import ViCareRequiredKeysMixin @@ -24,3 +28,30 @@ def is_supported( _LOGGER.debug("Attribute Error %s: %s", name, error) return False return True + + +def get_burners(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent]: + """Return the list of burners.""" + try: + return device.burners + except PyViCareNotSupportedFeatureError: + _LOGGER.debug("No burners found") + return [] + + +def get_circuits(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent]: + """Return the list of circuits.""" + try: + return device.circuits + except PyViCareNotSupportedFeatureError: + _LOGGER.debug("No circuits found") + return [] + + +def get_compressors(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent]: + """Return the list of compressors.""" + try: + return device.compressors + except PyViCareNotSupportedFeatureError: + _LOGGER.debug("No compressors found") + return [] diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index db8a959f4ae..66a90ca065b 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -1,8 +1,13 @@ """Viessmann ViCare water_heater device.""" +from __future__ import annotations + from contextlib import suppress import logging from typing import Any +from PyViCare.PyViCareDevice import Device as PyViCareDevice +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from PyViCare.PyViCareHeatingDevice import HeatingCircuit as PyViCareHeatingCircuit from PyViCare.PyViCareUtils import ( PyViCareInvalidDataError, PyViCareNotSupportedFeatureError, @@ -21,6 +26,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG from .entity import ViCareEntity +from .utils import get_circuits _LOGGER = logging.getLogger(__name__) @@ -54,13 +60,20 @@ HA_TO_VICARE_HVAC_DHW = { } -def _get_circuits(vicare_api): - """Return the list of circuits.""" - try: - return vicare_api.circuits - except PyViCareNotSupportedFeatureError: - _LOGGER.info("No circuits found") - return [] +def _build_entities( + api: PyViCareDevice, + device_config: PyViCareDeviceConfig, +) -> list[ViCareWater]: + """Create ViCare domestic hot water entities for a device.""" + return [ + ViCareWater( + api, + circuit, + device_config, + "domestic_hot_water", + ) + for circuit in get_circuits(api) + ] async def async_setup_entry( @@ -68,25 +81,17 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the ViCare climate platform.""" - entities = [] + """Set up the ViCare water heater platform.""" api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] - circuits = await hass.async_add_executor_job(_get_circuits, api) + device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] - for circuit in circuits: - suffix = "" - if len(circuits) > 1: - suffix = f" {circuit.id}" - - entity = ViCareWater( - f"Water{suffix}", + async_add_entities( + await hass.async_add_executor_job( + _build_entities, api, - circuit, - hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], + device_config, ) - entities.append(entity) - - async_add_entities(entities) + ) class ViCareWater(ViCareEntity, WaterHeaterEntity): @@ -99,15 +104,19 @@ class ViCareWater(ViCareEntity, WaterHeaterEntity): _attr_max_temp = VICARE_TEMP_WATER_MAX _attr_operation_list = list(HA_TO_VICARE_HVAC_DHW) - def __init__(self, name, api, circuit, device_config) -> None: + def __init__( + self, + api: PyViCareDevice, + circuit: PyViCareHeatingCircuit, + device_config: PyViCareDeviceConfig, + translation_key: str, + ) -> None: """Initialize the DHW water_heater device.""" - super().__init__(device_config) - self._attr_name = name - self._api = api + super().__init__(device_config, api, circuit.id) self._circuit = circuit self._attributes: dict[str, Any] = {} self._current_mode = None - self._attr_unique_id = f"{device_config.getConfig().serial}-{circuit.id}" + self._attr_translation_key = translation_key def update(self) -> None: """Let HA know there has been an update from the ViCare API.""" diff --git a/homeassistant/components/vilfo/strings.json b/homeassistant/components/vilfo/strings.json index d559e3a6716..f2c4c38780b 100644 --- a/homeassistant/components/vilfo/strings.json +++ b/homeassistant/components/vilfo/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "host": "Hostname or IP address of your Vilfo router." } } }, diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index 0ff64eeda53..6091cd72f3f 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -2,13 +2,15 @@ "config": { "step": { "user": { - "title": "VIZIO SmartCast Device", "description": "An access token is only needed for TVs. If you are configuring a TV and do not have an access token yet, leave it blank to go through a pairing process.", "data": { "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]", "device_class": "Device Type", "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "host": "Hostname or IP address of your VIZIO SmartCast device." } }, "pair_tv": { diff --git a/homeassistant/components/vlc_telnet/strings.json b/homeassistant/components/vlc_telnet/strings.json index 3a22bd06602..c0cacc734d3 100644 --- a/homeassistant/components/vlc_telnet/strings.json +++ b/homeassistant/components/vlc_telnet/strings.json @@ -14,6 +14,9 @@ "port": "[%key:common::config_flow::data::port%]", "password": "[%key:common::config_flow::data::password%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "Hostname or IP address of your VLC media player." } }, "hassio_confirm": { diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index a2cddcf9a65..ff51f009f3c 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -97,6 +97,9 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): try: try: await self.api.login() + raw_data_devices = await self.api.get_devices_data() + data_sensors = await self.api.get_sensor_data() + await self.api.logout() except exceptions.CannotAuthenticate as err: raise ConfigEntryAuthFailed from err except ( @@ -117,10 +120,8 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): dev_info, utc_point_in_time ), ) - for dev_info in (await self.api.get_devices_data()).values() + for dev_info in (raw_data_devices).values() } - data_sensors = await self.api.get_sensor_data() - await self.api.logout() return UpdateCoordinatorDataType(data_devices, data_sensors) @property diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 2a1814c83d0..20ea4db057e 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vodafone_station", "iot_class": "local_polling", "loggers": ["aiovodafone"], - "requirements": ["aiovodafone==0.4.2"] + "requirements": ["aiovodafone==0.4.3"] } diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py index 1bda3b1595d..8d9cb444fc9 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -28,9 +28,9 @@ NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"] class VodafoneStationBaseEntityDescription: """Vodafone Station entity base description.""" - value: Callable[ - [Any, Any], Any - ] = lambda coordinator, key: coordinator.data.sensors[key] + value: Callable[[Any, Any], Any] = ( + lambda coordinator, key: coordinator.data.sensors[key] + ) is_suitable: Callable[[dict], bool] = lambda val: True diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index aaaa27a3614..fab266ac47f 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -13,6 +13,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Vodafone Station." } } }, diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index 6ea97268684..11f70c631f1 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -5,10 +5,12 @@ import asyncio from collections import deque from collections.abc import AsyncIterable, MutableSequence, Sequence from functools import partial +import io import logging from pathlib import Path import time from typing import TYPE_CHECKING +import wave from voip_utils import ( CallInfo, @@ -37,7 +39,7 @@ from homeassistant.components.assist_pipeline.vad import ( ) from homeassistant.const import __version__ from homeassistant.core import Context, HomeAssistant -from homeassistant.util.ulid import ulid +from homeassistant.util.ulid import ulid_now from .const import CHANNELS, DOMAIN, RATE, RTP_AUDIO_SETTINGS, WIDTH @@ -111,11 +113,13 @@ class HassVoipDatagramProtocol(VoipDatagramProtocol): valid_protocol_factory=lambda call_info, rtcp_state: make_protocol( hass, devices, call_info, rtcp_state ), - invalid_protocol_factory=lambda call_info, rtcp_state: PreRecordMessageProtocol( - hass, - "not_configured.pcm", - opus_payload_type=call_info.opus_payload_type, - rtcp_state=rtcp_state, + invalid_protocol_factory=( + lambda call_info, rtcp_state: PreRecordMessageProtocol( + hass, + "not_configured.pcm", + opus_payload_type=call_info.opus_payload_type, + rtcp_state=rtcp_state, + ) ), ) self.hass = hass @@ -219,7 +223,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): ) -> None: """Forward audio to pipeline STT and handle TTS.""" if self._session_id is None: - self._session_id = ulid() + self._session_id = ulid_now() # Play listening tone at the start of each cycle if self.listening_tone_enabled: @@ -283,7 +287,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): ), conversation_id=self._conversation_id, device_id=self.voip_device.device_id, - tts_audio_output="raw", + tts_audio_output="wav", ) if self._pipeline_error: @@ -385,11 +389,16 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): self._conversation_id = event.data["intent_output"]["conversation_id"] elif event.type == PipelineEventType.TTS_END: # Send TTS audio to caller over RTP - media_id = event.data["tts_output"]["media_id"] - self.hass.async_create_background_task( - self._send_tts(media_id), - "voip_pipeline_tts", - ) + tts_output = event.data["tts_output"] + if tts_output: + media_id = tts_output["media_id"] + self.hass.async_create_background_task( + self._send_tts(media_id), + "voip_pipeline_tts", + ) + else: + # Empty TTS response + self._tts_done.set() elif event.type == PipelineEventType.ERROR: # Play error tone instead of wait for TTS self._pipeline_error = True @@ -400,11 +409,32 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): if self.transport is None: return - _extension, audio_bytes = await tts.async_get_media_source_audio( + extension, data = await tts.async_get_media_source_audio( self.hass, media_id, ) + if extension != "wav": + raise ValueError(f"Only WAV audio can be streamed, got {extension}") + + with io.BytesIO(data) as wav_io: + with wave.open(wav_io, "rb") as wav_file: + sample_rate = wav_file.getframerate() + sample_width = wav_file.getsampwidth() + sample_channels = wav_file.getnchannels() + + if ( + (sample_rate != 16000) + or (sample_width != 2) + or (sample_channels != 1) + ): + raise ValueError( + "Expected rate/width/channels as 16000/2/1," + " got {sample_rate}/{sample_width}/{sample_channels}}" + ) + + audio_bytes = wav_file.readframes(wav_file.getnframes()) + _LOGGER.debug("Sending %s byte(s) of audio", len(audio_bytes)) # Time out 1 second after TTS audio should be finished @@ -412,7 +442,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): tts_seconds = tts_samples / RATE async with asyncio.timeout(tts_seconds + self.tts_extra_timeout): - # Assume TTS audio is 16Khz 16-bit mono + # TTS audio is 16Khz 16-bit mono await self._async_send_audio(audio_bytes) except asyncio.TimeoutError as err: _LOGGER.warning("TTS timeout") diff --git a/homeassistant/components/volumio/strings.json b/homeassistant/components/volumio/strings.json index ba283a3af37..32552ad7386 100644 --- a/homeassistant/components/volumio/strings.json +++ b/homeassistant/components/volumio/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "Hostname or IP address of your Volumio media player." } }, "discovery_confirm": { diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index b9248d8ce5b..b3c5a9b4910 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -1,17 +1,18 @@ """DataUpdateCoordinator for the wallbox integration.""" from __future__ import annotations +from collections.abc import Callable from datetime import timedelta from http import HTTPStatus import logging -from typing import Any +from typing import Any, Concatenate, ParamSpec, TypeVar import requests from wallbox import Wallbox from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( CHARGER_CURRENCY_KEY, @@ -62,6 +63,29 @@ CHARGER_STATUS: dict[int, ChargerStatus] = { 210: ChargerStatus.LOCKED_CAR_CONNECTED, } +_WallboxCoordinatorT = TypeVar("_WallboxCoordinatorT", bound="WallboxCoordinator") +_P = ParamSpec("_P") + + +def _require_authentication( + func: Callable[Concatenate[_WallboxCoordinatorT, _P], Any] +) -> Callable[Concatenate[_WallboxCoordinatorT, _P], Any]: + """Authenticate with decorator using Wallbox API.""" + + def require_authentication( + self: _WallboxCoordinatorT, *args: _P.args, **kwargs: _P.kwargs + ) -> Any: + """Authenticate using Wallbox API.""" + try: + self.authenticate() + return func(self, *args, **kwargs) + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == HTTPStatus.FORBIDDEN: + raise ConfigEntryAuthFailed from wallbox_connection_error + raise ConnectionError from wallbox_connection_error + + return require_authentication + class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Wallbox Coordinator class.""" @@ -78,15 +102,9 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): update_interval=timedelta(seconds=UPDATE_INTERVAL), ) - def _authenticate(self) -> None: + def authenticate(self) -> None: """Authenticate using Wallbox API.""" - try: - self._wallbox.authenticate() - - except requests.exceptions.HTTPError as wallbox_connection_error: - if wallbox_connection_error.response.status_code == HTTPStatus.FORBIDDEN: - raise ConfigEntryAuthFailed from wallbox_connection_error - raise ConnectionError from wallbox_connection_error + self._wallbox.authenticate() def _validate(self) -> None: """Authenticate using Wallbox API.""" @@ -101,47 +119,41 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Get new sensor data for Wallbox component.""" await self.hass.async_add_executor_job(self._validate) + @_require_authentication def _get_data(self) -> dict[str, Any]: """Get new sensor data for Wallbox component.""" - try: - self._authenticate() - data: dict[str, Any] = self._wallbox.getChargerStatus(self._station) - data[CHARGER_MAX_CHARGING_CURRENT_KEY] = data[CHARGER_DATA_KEY][ - CHARGER_MAX_CHARGING_CURRENT_KEY - ] - data[CHARGER_LOCKED_UNLOCKED_KEY] = data[CHARGER_DATA_KEY][ - CHARGER_LOCKED_UNLOCKED_KEY - ] - data[CHARGER_ENERGY_PRICE_KEY] = data[CHARGER_DATA_KEY][ - CHARGER_ENERGY_PRICE_KEY - ] - data[ - CHARGER_CURRENCY_KEY - ] = f"{data[CHARGER_DATA_KEY][CHARGER_CURRENCY_KEY][CODE_KEY]}/kWh" + data: dict[str, Any] = self._wallbox.getChargerStatus(self._station) + data[CHARGER_MAX_CHARGING_CURRENT_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_MAX_CHARGING_CURRENT_KEY + ] + data[CHARGER_LOCKED_UNLOCKED_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_LOCKED_UNLOCKED_KEY + ] + data[CHARGER_ENERGY_PRICE_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_ENERGY_PRICE_KEY + ] + data[ + CHARGER_CURRENCY_KEY + ] = f"{data[CHARGER_DATA_KEY][CHARGER_CURRENCY_KEY][CODE_KEY]}/kWh" - data[CHARGER_STATUS_DESCRIPTION_KEY] = CHARGER_STATUS.get( - data[CHARGER_STATUS_ID_KEY], ChargerStatus.UNKNOWN - ) - return data - except ( - ConnectionError, - requests.exceptions.HTTPError, - ) as wallbox_connection_error: - raise UpdateFailed from wallbox_connection_error + data[CHARGER_STATUS_DESCRIPTION_KEY] = CHARGER_STATUS.get( + data[CHARGER_STATUS_ID_KEY], ChargerStatus.UNKNOWN + ) + return data async def _async_update_data(self) -> dict[str, Any]: """Get new sensor data for Wallbox component.""" return await self.hass.async_add_executor_job(self._get_data) + @_require_authentication def _set_charging_current(self, charging_current: float) -> None: """Set maximum charging current for Wallbox.""" try: - self._authenticate() self._wallbox.setMaxChargingCurrent(self._station, charging_current) except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error - raise ConnectionError from wallbox_connection_error + raise wallbox_connection_error async def async_set_charging_current(self, charging_current: float) -> None: """Set maximum charging current for Wallbox.""" @@ -150,25 +162,21 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) await self.async_request_refresh() + @_require_authentication def _set_energy_cost(self, energy_cost: float) -> None: """Set energy cost for Wallbox.""" - try: - self._authenticate() - self._wallbox.setEnergyCost(self._station, energy_cost) - except requests.exceptions.HTTPError as wallbox_connection_error: - if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth from wallbox_connection_error - raise ConnectionError from wallbox_connection_error + + self._wallbox.setEnergyCost(self._station, energy_cost) async def async_set_energy_cost(self, energy_cost: float) -> None: """Set energy cost for Wallbox.""" await self.hass.async_add_executor_job(self._set_energy_cost, energy_cost) await self.async_request_refresh() + @_require_authentication def _set_lock_unlock(self, lock: bool) -> None: """Set wallbox to locked or unlocked.""" try: - self._authenticate() if lock: self._wallbox.lockCharger(self._station) else: @@ -176,25 +184,21 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error - raise ConnectionError from wallbox_connection_error + raise wallbox_connection_error async def async_set_lock_unlock(self, lock: bool) -> None: """Set wallbox to locked or unlocked.""" await self.hass.async_add_executor_job(self._set_lock_unlock, lock) await self.async_request_refresh() + @_require_authentication def _pause_charger(self, pause: bool) -> None: """Set wallbox to pause or resume.""" - try: - self._authenticate() - if pause: - self._wallbox.pauseChargingSession(self._station) - else: - self._wallbox.resumeChargingSession(self._station) - except requests.exceptions.HTTPError as wallbox_connection_error: - if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth from wallbox_connection_error - raise ConnectionError from wallbox_connection_error + + if pause: + self._wallbox.pauseChargingSession(self._station) + else: + self._wallbox.resumeChargingSession(self._station) async def async_pause_charger(self, pause: bool) -> None: """Set wallbox to pause or resume.""" diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 9694e13103c..b47eb14d58a 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -35,7 +35,7 @@ def min_charging_current_value(coordinator: WallboxCoordinator) -> float: in BIDIRECTIONAL_MODEL_PREFIXES ): return cast(float, (coordinator.data[CHARGER_MAX_AVAILABLE_POWER_KEY] * -1)) - return 0 + return 6 @dataclass diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json index f5731da2a7e..d742fd72858 100644 --- a/homeassistant/components/waqi/manifest.json +++ b/homeassistant/components/waqi/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/waqi", "iot_class": "cloud_polling", "loggers": ["aiowaqi"], - "requirements": ["aiowaqi==3.0.0"] + "requirements": ["aiowaqi==3.0.1"] } diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 648201f16d2..3d9eccd9425 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -135,7 +135,9 @@ SCAN_INTERVAL = timedelta(seconds=30) ROUNDING_PRECISION = 2 -SERVICE_GET_FORECAST: Final = "get_forecast" +LEGACY_SERVICE_GET_FORECAST: Final = "get_forecast" +"""Deprecated: please use SERVICE_GET_FORECASTS.""" +SERVICE_GET_FORECASTS: Final = "get_forecasts" _ObservationUpdateCoordinatorT = TypeVar( "_ObservationUpdateCoordinatorT", bound="DataUpdateCoordinator[Any]" @@ -210,8 +212,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component = hass.data[DOMAIN] = EntityComponent[WeatherEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) - component.async_register_entity_service( - SERVICE_GET_FORECAST, + component.async_register_legacy_entity_service( + LEGACY_SERVICE_GET_FORECAST, {vol.Required("type"): vol.In(("daily", "hourly", "twice_daily"))}, async_get_forecast_service, required_features=[ @@ -221,6 +223,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ], supports_response=SupportsResponse.ONLY, ) + component.async_register_entity_service( + SERVICE_GET_FORECASTS, + {vol.Required("type"): vol.In(("daily", "hourly", "twice_daily"))}, + async_get_forecasts_service, + required_features=[ + WeatherEntityFeature.FORECAST_DAILY, + WeatherEntityFeature.FORECAST_HOURLY, + WeatherEntityFeature.FORECAST_TWICE_DAILY, + ], + supports_response=SupportsResponse.ONLY, + ) async_setup_ws_api(hass) await component.async_setup(config) return True @@ -1086,6 +1099,32 @@ def raise_unsupported_forecast(entity_id: str, forecast_type: str) -> None: async def async_get_forecast_service( weather: WeatherEntity, service_call: ServiceCall +) -> ServiceResponse: + """Get weather forecast. + + Deprecated: please use async_get_forecasts_service. + """ + _LOGGER.warning( + "Detected use of service 'weather.get_forecast'. " + "This is deprecated and will stop working in Home Assistant 2024.6. " + "Use 'weather.get_forecasts' instead which supports multiple entities", + ) + ir.async_create_issue( + weather.hass, + DOMAIN, + "deprecated_service_weather_get_forecast", + breaks_in_ha_version="2024.6.0", + is_fixable=True, + is_persistent=False, + issue_domain=weather.platform.platform_name, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_service_weather_get_forecast", + ) + return await async_get_forecasts_service(weather, service_call) + + +async def async_get_forecasts_service( + weather: WeatherEntity, service_call: ServiceCall ) -> ServiceResponse: """Get weather forecast.""" forecast_type = service_call.data["type"] diff --git a/homeassistant/components/weather/intent.py b/homeassistant/components/weather/intent.py new file mode 100644 index 00000000000..4fd22ceb0a9 --- /dev/null +++ b/homeassistant/components/weather/intent.py @@ -0,0 +1,85 @@ +"""Intents for the weather integration.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import intent +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent + +from . import DOMAIN, WeatherEntity + +INTENT_GET_WEATHER = "HassGetWeather" + + +async def async_setup_intents(hass: HomeAssistant) -> None: + """Set up the weather intents.""" + intent.async_register(hass, GetWeatherIntent()) + + +class GetWeatherIntent(intent.IntentHandler): + """Handle GetWeather intents.""" + + intent_type = INTENT_GET_WEATHER + slot_schema = {vol.Optional("name"): cv.string} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + + weather: WeatherEntity | None = None + weather_state: State | None = None + component: EntityComponent[WeatherEntity] = hass.data[DOMAIN] + entities = list(component.entities) + + if "name" in slots: + # Named weather entity + weather_name = slots["name"]["value"] + + # Find matching weather entity + matching_states = intent.async_match_states( + hass, name=weather_name, domains=[DOMAIN] + ) + for maybe_weather_state in matching_states: + weather = component.get_entity(maybe_weather_state.entity_id) + if weather is not None: + weather_state = maybe_weather_state + break + + if weather is None: + raise intent.IntentHandleError( + f"No weather entity named {weather_name}" + ) + elif entities: + # First weather entity + weather = entities[0] + weather_name = weather.name + weather_state = hass.states.get(weather.entity_id) + + if weather is None: + raise intent.IntentHandleError("No weather entity") + + if weather_state is None: + raise intent.IntentHandleError(f"No state for weather: {weather.name}") + + assert weather is not None + assert weather_state is not None + + # Create response + response = intent_obj.create_response() + response.response_type = intent.IntentResponseType.QUERY_ANSWER + response.async_set_results( + success_results=[ + intent.IntentResponseTarget( + type=intent.IntentResponseTargetType.ENTITY, + name=weather_name, + id=weather.entity_id, + ) + ] + ) + + response.async_set_states(matched_states=[weather_state]) + + return response diff --git a/homeassistant/components/weather/services.yaml b/homeassistant/components/weather/services.yaml index b2b71396fab..222dbf596d0 100644 --- a/homeassistant/components/weather/services.yaml +++ b/homeassistant/components/weather/services.yaml @@ -16,3 +16,21 @@ get_forecast: - "hourly" - "twice_daily" translation_key: forecast_type +get_forecasts: + target: + entity: + domain: weather + supported_features: + - weather.WeatherEntityFeature.FORECAST_DAILY + - weather.WeatherEntityFeature.FORECAST_HOURLY + - weather.WeatherEntityFeature.FORECAST_TWICE_DAILY + fields: + type: + required: true + selector: + select: + options: + - "daily" + - "hourly" + - "twice_daily" + translation_key: forecast_type diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index f76e93c66c3..0b712a4de05 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -88,13 +88,23 @@ } }, "services": { + "get_forecasts": { + "name": "Get forecasts", + "description": "Get weather forecasts.", + "fields": { + "type": { + "name": "Forecast type", + "description": "Forecast type: daily, hourly or twice daily." + } + } + }, "get_forecast": { "name": "Get forecast", "description": "Get weather forecast.", "fields": { "type": { - "name": "Forecast type", - "description": "Forecast type: daily, hourly or twice daily." + "name": "[%key:component::weather::services::get_forecasts::fields::type::name%]", + "description": "[%key:component::weather::services::get_forecasts::fields::type::description%]" } } } @@ -107,6 +117,17 @@ "deprecated_weather_forecast_no_url": { "title": "[%key:component::weather::issues::deprecated_weather_forecast_url::title%]", "description": "The custom integration `{platform}` implements the `forecast` property or sets `self._attr_forecast` in a subclass of WeatherEntity.\n\nPlease report it to the author of the {platform} integration.\n\nOnce an updated version of `{platform}` is available, install it and restart Home Assistant to fix this issue." + }, + "deprecated_service_weather_get_forecast": { + "title": "Detected use of deprecated service `weather.get_forecast`", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::weather::issues::deprecated_service_weather_get_forecast::title%]", + "description": "Use `weather.get_forecasts` instead which supports multiple entities.\n\nPlease replace this service and adjust your automations and scripts and select **submit** to close this issue." + } + } + } } } } diff --git a/homeassistant/components/weatherflow/__init__.py b/homeassistant/components/weatherflow/__init__.py index c64450babe7..fbd206b63f5 100644 --- a/homeassistant/components/weatherflow/__init__.py +++ b/homeassistant/components/weatherflow/__init__.py @@ -9,6 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.start import async_at_started @@ -75,3 +76,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await client.stop_listening() return unload_ok + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + client: WeatherFlowListener = hass.data[DOMAIN][config_entry.entry_id] + return not any( + identifier + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + for device in client.devices + if device.serial_number == identifier[1] + ) diff --git a/homeassistant/components/weatherflow/strings.json b/homeassistant/components/weatherflow/strings.json index 8f7a98abe04..d075ee34a05 100644 --- a/homeassistant/components/weatherflow/strings.json +++ b/homeassistant/components/weatherflow/strings.json @@ -2,10 +2,12 @@ "config": { "step": { "user": { - "title": "WeatherFlow discovery", "description": "Unable to discover Tempest WeatherFlow devices. Click submit to try again.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Tempest WeatherFlow device." } } }, diff --git a/homeassistant/components/weatherkit/manifest.json b/homeassistant/components/weatherkit/manifest.json index d28a6ff3315..a2ddde02ad4 100644 --- a/homeassistant/components/weatherkit/manifest.json +++ b/homeassistant/components/weatherkit/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/weatherkit", "iot_class": "cloud_polling", - "requirements": ["apple_weatherkit==1.0.4"] + "requirements": ["apple_weatherkit==1.1.1"] } diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index 5f82ca54283..16f3e5c7ef2 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -17,7 +17,7 @@ from homeassistant.components import websocket_api from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.network import get_url +from homeassistant.helpers.network import get_url, is_cloud_connection from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util import network @@ -145,13 +145,8 @@ async def async_handle_webhook( return Response(status=HTTPStatus.METHOD_NOT_ALLOWED) if webhook["local_only"] in (True, None) and not isinstance(request, MockRequest): - if has_cloud := "cloud" in hass.config.components: - from hass_nabucasa import remote # pylint: disable=import-outside-toplevel - - is_local = True - if has_cloud and remote.is_cloud_request.get(): - is_local = False - else: + is_local = not is_cloud_connection(hass) + if is_local: if TYPE_CHECKING: assert isinstance(request, Request) assert request.remote is not None diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json index a5e7b73e59e..1d045d48ba5 100644 --- a/homeassistant/components/webostv/strings.json +++ b/homeassistant/components/webostv/strings.json @@ -3,11 +3,13 @@ "flow_title": "LG webOS Smart TV", "step": { "user": { - "title": "Connect to webOS TV", "description": "Turn on TV, fill the following fields click submit", "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "Hostname or IP address of your webOS TV." } }, "pairing": { diff --git a/homeassistant/components/websocket_api/__init__.py b/homeassistant/components/websocket_api/__init__.py index 9c2645aec57..f7086cc81db 100644 --- a/homeassistant/components/websocket_api/__init__.py +++ b/homeassistant/components/websocket_api/__init__.py @@ -17,6 +17,7 @@ from .const import ( # noqa: F401 ERR_INVALID_FORMAT, ERR_NOT_FOUND, ERR_NOT_SUPPORTED, + ERR_SERVICE_VALIDATION_ERROR, ERR_TEMPLATE_ERROR, ERR_TIMEOUT, ERR_UNAUTHORIZED, diff --git a/homeassistant/components/websocket_api/auth.py b/homeassistant/components/websocket_api/auth.py index 9f8e8bfb6f8..2c86a26efc9 100644 --- a/homeassistant/components/websocket_api/auth.py +++ b/homeassistant/components/websocket_api/auth.py @@ -103,7 +103,7 @@ class AuthPhase: ) -> ActiveConnection: """Create an active connection.""" self._logger.debug("Auth OK") - await process_success_login(self._request) + process_success_login(self._request) self._send_message(auth_ok_message()) return ActiveConnection( self._logger, self._hass, self._send_message, user, refresh_token diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 2dfa48c28fe..cb90b46e182 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -18,10 +18,18 @@ from homeassistant.const import ( MATCH_ALL, SIGNAL_BOOTSTRAP_INTEGRATIONS, ) -from homeassistant.core import Context, Event, HomeAssistant, State, callback +from homeassistant.core import ( + Context, + Event, + HomeAssistant, + ServiceResponse, + State, + callback, +) from homeassistant.exceptions import ( HomeAssistantError, ServiceNotFound, + ServiceValidationError, TemplateError, Unauthorized, ) @@ -53,7 +61,7 @@ from homeassistant.util.json import format_unserializable_data from . import const, decorators, messages from .connection import ActiveConnection -from .messages import construct_event_message, construct_result_message +from .messages import construct_result_message ALL_SERVICE_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_service_descriptions_json" @@ -94,6 +102,7 @@ def pong_message(iden: int) -> dict[str, Any]: return {"id": iden, "type": "pong"} +@callback def _forward_events_check_permissions( send_message: Callable[[str | dict[str, Any] | Callable[[], str]], None], user: User, @@ -111,6 +120,7 @@ def _forward_events_check_permissions( send_message(messages.cached_event_message(msg_id, event)) +@callback def _forward_events_unconditional( send_message: Callable[[str | dict[str, Any] | Callable[[], str]], None], msg_id: int, @@ -142,17 +152,15 @@ def handle_subscribe_events( raise Unauthorized(user_id=connection.user.id) if event_type == EVENT_STATE_CHANGED: - forward_events = callback( - partial( - _forward_events_check_permissions, - connection.send_message, - connection.user, - msg["id"], - ) + forward_events = partial( + _forward_events_check_permissions, + connection.send_message, + connection.user, + msg["id"], ) else: - forward_events = callback( - partial(_forward_events_unconditional, connection.send_message, msg["id"]) + forward_events = partial( + _forward_events_unconditional, connection.send_message, msg["id"] ) connection.subscriptions[msg["id"]] = hass.bus.async_listen( @@ -212,6 +220,7 @@ def handle_unsubscribe_events( vol.Required("service"): str, vol.Optional("target"): cv.ENTITY_SERVICE_FIELDS, vol.Optional("service_data"): dict, + vol.Optional("return_response", default=False): bool, } ) @decorators.async_response @@ -219,7 +228,6 @@ async def handle_call_service( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle call service command.""" - blocking = True # We do not support templates. target = msg.get("target") if template.is_complex(target): @@ -227,25 +235,68 @@ async def handle_call_service( try: context = connection.context(msg) - await hass.services.async_call( - msg["domain"], - msg["service"], - msg.get("service_data"), - blocking, - context, + response = await hass.services.async_call( + domain=msg["domain"], + service=msg["service"], + service_data=msg.get("service_data"), + blocking=True, + context=context, target=target, + return_response=msg["return_response"], ) - connection.send_result(msg["id"], {"context": context}) + result: dict[str, Context | ServiceResponse] = {"context": context} + if msg["return_response"]: + result["response"] = response + connection.send_result(msg["id"], result) except ServiceNotFound as err: if err.domain == msg["domain"] and err.service == msg["service"]: - connection.send_error(msg["id"], const.ERR_NOT_FOUND, "Service not found.") + connection.send_error( + msg["id"], + const.ERR_NOT_FOUND, + f"Service {err.domain}.{err.service} not found.", + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) else: - connection.send_error(msg["id"], const.ERR_HOME_ASSISTANT_ERROR, str(err)) + # The called service called another service which does not exist + connection.send_error( + msg["id"], + const.ERR_HOME_ASSISTANT_ERROR, + f"Service {err.domain}.{err.service} called service " + f"{msg['domain']}.{msg['service']} which was not found.", + translation_domain=const.DOMAIN, + translation_key="child_service_not_found", + translation_placeholders={ + "domain": err.domain, + "service": err.service, + "child_domain": msg["domain"], + "child_service": msg["service"], + }, + ) except vol.Invalid as err: connection.send_error(msg["id"], const.ERR_INVALID_FORMAT, str(err)) + except ServiceValidationError as err: + connection.logger.error(err) + connection.logger.debug("", exc_info=err) + connection.send_error( + msg["id"], + const.ERR_SERVICE_VALIDATION_ERROR, + f"Validation error: {err}", + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) except HomeAssistantError as err: connection.logger.exception(err) - connection.send_error(msg["id"], const.ERR_HOME_ASSISTANT_ERROR, str(err)) + connection.send_error( + msg["id"], + const.ERR_HOME_ASSISTANT_ERROR, + str(err), + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) except Exception as err: # pylint: disable=broad-except connection.logger.exception(err) connection.send_error(msg["id"], const.ERR_UNKNOWN_ERROR, str(err)) @@ -301,10 +352,12 @@ def _send_handle_get_states_response( connection: ActiveConnection, msg_id: int, serialized_states: list[str] ) -> None: """Send handle get states response.""" - joined_states = ",".join(serialized_states) - connection.send_message(construct_result_message(msg_id, f"[{joined_states}]")) + connection.send_message( + construct_result_message(msg_id, f'[{",".join(serialized_states)}]') + ) +@callback def _forward_entity_changes( send_message: Callable[[str | dict[str, Any] | Callable[[], str]], None], entity_ids: set[str], @@ -344,14 +397,12 @@ def handle_subscribe_entities( states = _async_get_allowed_states(hass, connection) connection.subscriptions[msg["id"]] = hass.bus.async_listen( EVENT_STATE_CHANGED, - callback( - partial( - _forward_entity_changes, - connection.send_message, - entity_ids, - connection.user, - msg["id"], - ) + partial( + _forward_entity_changes, + connection.send_message, + entity_ids, + connection.user, + msg["id"], ), run_immediately=True, ) @@ -391,9 +442,8 @@ def _send_handle_entities_init_response( connection: ActiveConnection, msg_id: int, serialized_states: list[str] ) -> None: """Send handle entities init response.""" - joined_states = ",".join(serialized_states) connection.send_message( - construct_event_message(msg_id, f'{{"a":{{{joined_states}}}}}') + f'{{"id":{msg_id},"type":"event","event":{{"a":{{{",".join(serialized_states)}}}}}}}' ) @@ -728,7 +778,22 @@ async def handle_execute_script( context = connection.context(msg) script_obj = Script(hass, script_config, f"{const.DOMAIN} script", const.DOMAIN) - script_result = await script_obj.async_run(msg.get("variables"), context=context) + try: + script_result = await script_obj.async_run( + msg.get("variables"), context=context + ) + except ServiceValidationError as err: + connection.logger.error(err) + connection.logger.debug("", exc_info=err) + connection.send_error( + msg["id"], + const.ERR_SERVICE_VALIDATION_ERROR, + str(err), + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) + return connection.send_result( msg["id"], { diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 1dbda62ab95..25b6c90d1ba 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -134,9 +134,26 @@ class ActiveConnection: self.send_message(messages.event_message(msg_id, event)) @callback - def send_error(self, msg_id: int, code: str, message: str) -> None: - """Send a error message.""" - self.send_message(messages.error_message(msg_id, code, message)) + def send_error( + self, + msg_id: int, + code: str, + message: str, + translation_key: str | None = None, + translation_domain: str | None = None, + translation_placeholders: dict[str, Any] | None = None, + ) -> None: + """Send an error message.""" + self.send_message( + messages.error_message( + msg_id, + code, + message, + translation_key=translation_key, + translation_domain=translation_domain, + translation_placeholders=translation_placeholders, + ) + ) @callback def async_handle_binary(self, handler_id: int, payload: bytes) -> None: @@ -238,7 +255,10 @@ class ActiveConnection: log_handler = self.logger.error code = const.ERR_UNKNOWN_ERROR - err_message = None + err_message: str | None = None + translation_domain: str | None = None + translation_key: str | None = None + translation_placeholders: dict[str, Any] | None = None if isinstance(err, Unauthorized): code = const.ERR_UNAUTHORIZED @@ -251,6 +271,10 @@ class ActiveConnection: err_message = "Timeout" elif isinstance(err, HomeAssistantError): err_message = str(err) + code = const.ERR_HOME_ASSISTANT_ERROR + translation_domain = err.translation_domain + translation_key = err.translation_key + translation_placeholders = err.translation_placeholders # This if-check matches all other errors but also matches errors which # result in an empty message. In that case we will also log the stack @@ -259,7 +283,16 @@ class ActiveConnection: err_message = "Unknown error" log_handler = self.logger.exception - self.send_message(messages.error_message(msg["id"], code, err_message)) + self.send_message( + messages.error_message( + msg["id"], + code, + err_message, + translation_domain=translation_domain, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + ) if code: err_message += f" ({code})" diff --git a/homeassistant/components/websocket_api/const.py b/homeassistant/components/websocket_api/const.py index 4b9a0943d9a..9a44f80a5c8 100644 --- a/homeassistant/components/websocket_api/const.py +++ b/homeassistant/components/websocket_api/const.py @@ -32,6 +32,7 @@ ERR_NOT_ALLOWED: Final = "not_allowed" ERR_NOT_FOUND: Final = "not_found" ERR_NOT_SUPPORTED: Final = "not_supported" ERR_HOME_ASSISTANT_ERROR: Final = "home_assistant_error" +ERR_SERVICE_VALIDATION_ERROR: Final = "service_validation_error" ERR_UNKNOWN_COMMAND: Final = "unknown_command" ERR_UNKNOWN_ERROR: Final = "unknown_error" ERR_UNAUTHORIZED: Final = "unauthorized" diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 238cd6d7465..f2f667368c3 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -159,8 +159,7 @@ class WebSocketHandler: messages.append(message) messages_remaining -= 1 - joined_messages = ",".join(messages) - coalesced_messages = f"[{joined_messages}]" + coalesced_messages = f'[{",".join(messages)}]' if debug_enabled: debug("%s: Sending %s", self.description, coalesced_messages) await send_str(coalesced_messages) diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index e1b038f4222..34ca6886b5e 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -65,20 +65,32 @@ def construct_result_message(iden: int, payload: str) -> str: return f'{{"id":{iden},"type":"result","success":true,"result":{payload}}}' -def error_message(iden: int | None, code: str, message: str) -> dict[str, Any]: +def error_message( + iden: int | None, + code: str, + message: str, + translation_key: str | None = None, + translation_domain: str | None = None, + translation_placeholders: dict[str, Any] | None = None, +) -> dict[str, Any]: """Return an error result message.""" + error_payload: dict[str, Any] = { + "code": code, + "message": message, + } + # In case `translation_key` is `None` we do not set it, nor the + # `translation`_placeholders` and `translation_domain`. + if translation_key is not None: + error_payload["translation_key"] = translation_key + error_payload["translation_placeholders"] = translation_placeholders + error_payload["translation_domain"] = translation_domain return { "id": iden, **BASE_ERROR_MESSAGE, - "error": {"code": code, "message": message}, + "error": error_payload, } -def construct_event_message(iden: int, payload: str) -> str: - """Construct an event message JSON.""" - return f'{{"id":{iden},"type":"event","event":{payload}}}' - - def event_message(iden: int, event: Any) -> dict[str, Any]: """Return an event message.""" return {"id": iden, "type": "event", "event": event} diff --git a/homeassistant/components/websocket_api/strings.json b/homeassistant/components/websocket_api/strings.json new file mode 100644 index 00000000000..10b95637b6b --- /dev/null +++ b/homeassistant/components/websocket_api/strings.json @@ -0,0 +1,7 @@ +{ + "exceptions": { + "child_service_not_found": { + "message": "Service {domain}.{service} called service {child_domain}.{child_service} which was not found." + } + } +} diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py index beca3540e8e..0116f542a3c 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -27,20 +27,13 @@ from homeassistant.util import dt as dt_util from .const import ATTR_EXPIRES, ATTR_NAME_SERVERS, ATTR_REGISTRAR, ATTR_UPDATED, DOMAIN -@dataclass -class WhoisSensorEntityDescriptionMixin: - """Mixin for required keys.""" +@dataclass(kw_only=True) +class WhoisSensorEntityDescription(SensorEntityDescription): + """Describes a Whois sensor entity.""" value_fn: Callable[[Domain], datetime | int | str | None] -@dataclass -class WhoisSensorEntityDescription( - SensorEntityDescription, WhoisSensorEntityDescriptionMixin -): - """Describes a Whois sensor entity.""" - - def _days_until_expiration(domain: Domain) -> int | None: """Calculate days left until domain expires.""" if domain.expiration_date is None: diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index 06fbfa3621e..f95337dbaf4 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -5,6 +5,7 @@ from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol from wirelesstagpy import WirelessTags from wirelesstagpy.exceptions import WirelessTagsException +from wirelesstagpy.sensortag import SensorTag from homeassistant.components import persistent_notification from homeassistant.const import ( @@ -17,6 +18,7 @@ from homeassistant.const import ( UnitOfElectricPotential, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.entity import Entity @@ -126,6 +128,20 @@ class WirelessTagPlatform: self.api.start_monitoring(push_callback) +def async_migrate_unique_id(hass: HomeAssistant, tag: SensorTag, domain: str, key: str): + """Migrate old unique id to new one with use of tag's uuid.""" + registry = er.async_get(hass) + new_unique_id = f"{tag.uuid}_{key}" + + if registry.async_get_entity_id(domain, DOMAIN, new_unique_id): + return + + old_unique_id = f"{tag.tag_id}_{key}" + if entity_id := registry.async_get_entity_id(domain, DOMAIN, old_unique_id): + _LOGGER.debug("Updating unique id for %s %s", key, entity_id) + registry.async_update_entity(entity_id, new_unique_id=new_unique_id) + + def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Wireless Sensor Tag component.""" conf = config[DOMAIN] diff --git a/homeassistant/components/wirelesstag/binary_sensor.py b/homeassistant/components/wirelesstag/binary_sensor.py index 711c2987735..64a1097bcab 100644 --- a/homeassistant/components/wirelesstag/binary_sensor.py +++ b/homeassistant/components/wirelesstag/binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations import voluptuous as vol from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity -from homeassistant.const import CONF_MONITORED_CONDITIONS, STATE_OFF, STATE_ON +from homeassistant.const import CONF_MONITORED_CONDITIONS, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -15,6 +15,7 @@ from . import ( DOMAIN as WIRELESSTAG_DOMAIN, SIGNAL_BINARY_EVENT_UPDATE, WirelessTagBaseSensor, + async_migrate_unique_id, ) # On means in range, Off means out of range @@ -72,10 +73,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the platform for a WirelessTags.""" @@ -87,9 +88,10 @@ def setup_platform( allowed_sensor_types = tag.supported_binary_events_types for sensor_type in config[CONF_MONITORED_CONDITIONS]: if sensor_type in allowed_sensor_types: + async_migrate_unique_id(hass, tag, Platform.BINARY_SENSOR, sensor_type) sensors.append(WirelessTagBinarySensor(platform, tag, sensor_type)) - add_entities(sensors, True) + async_add_entities(sensors, True) class WirelessTagBinarySensor(WirelessTagBaseSensor, BinarySensorEntity): @@ -100,7 +102,7 @@ class WirelessTagBinarySensor(WirelessTagBaseSensor, BinarySensorEntity): super().__init__(api, tag) self._sensor_type = sensor_type self._name = f"{self._tag.name} {self.event.human_readable_name}" - self._attr_unique_id = f"{self.tag_id}_{self._sensor_type}" + self._attr_unique_id = f"{self._uuid}_{self._sensor_type}" async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py index fd9a7898f92..8ae20031723 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -12,14 +12,19 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.const import CONF_MONITORED_CONDITIONS, Platform from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as WIRELESSTAG_DOMAIN, SIGNAL_TAG_UPDATE, WirelessTagBaseSensor +from . import ( + DOMAIN as WIRELESSTAG_DOMAIN, + SIGNAL_TAG_UPDATE, + WirelessTagBaseSensor, + async_migrate_unique_id, +) _LOGGER = logging.getLogger(__name__) @@ -68,10 +73,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the sensor platform.""" @@ -83,9 +88,10 @@ def setup_platform( if key not in tag.allowed_sensor_types: continue description = SENSOR_TYPES[key] + async_migrate_unique_id(hass, tag, Platform.SENSOR, description.key) sensors.append(WirelessTagSensor(platform, tag, description)) - add_entities(sensors, True) + async_add_entities(sensors, True) class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): @@ -100,7 +106,7 @@ class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): self._sensor_type = description.key self.entity_description = description self._name = self._tag.name - self._attr_unique_id = f"{self.tag_id}_{self._sensor_type}" + self._attr_unique_id = f"{self._uuid}_{self._sensor_type}" # I want to see entity_id as: # sensor.wirelesstag_bedroom_temperature diff --git a/homeassistant/components/wirelesstag/switch.py b/homeassistant/components/wirelesstag/switch.py index df0f72aca18..7f4008623b1 100644 --- a/homeassistant/components/wirelesstag/switch.py +++ b/homeassistant/components/wirelesstag/switch.py @@ -10,13 +10,17 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.const import CONF_MONITORED_CONDITIONS, Platform 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 . import DOMAIN as WIRELESSTAG_DOMAIN, WirelessTagBaseSensor +from . import ( + DOMAIN as WIRELESSTAG_DOMAIN, + WirelessTagBaseSensor, + async_migrate_unique_id, +) SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( SwitchEntityDescription( @@ -52,10 +56,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up switches for a Wireless Sensor Tags.""" @@ -63,15 +67,17 @@ def setup_platform( tags = platform.load_tags() monitored_conditions = config[CONF_MONITORED_CONDITIONS] - entities = [ - WirelessTagSwitch(platform, tag, description) - for tag in tags.values() - for description in SWITCH_TYPES - if description.key in monitored_conditions - and description.key in tag.allowed_monitoring_types - ] + entities = [] + for tag in tags.values(): + for description in SWITCH_TYPES: + if ( + description.key in monitored_conditions + and description.key in tag.allowed_monitoring_types + ): + async_migrate_unique_id(hass, tag, Platform.SWITCH, description.key) + entities.append(WirelessTagSwitch(platform, tag, description)) - add_entities(entities, True) + async_add_entities(entities, True) class WirelessTagSwitch(WirelessTagBaseSensor, SwitchEntity): @@ -82,7 +88,7 @@ class WirelessTagSwitch(WirelessTagBaseSensor, SwitchEntity): super().__init__(api, tag) self.entity_description = description self._name = f"{self._tag.name} {description.name}" - self._attr_unique_id = f"{self.tag_id}_{description.key}" + self._attr_unique_id = f"{self._uuid}_{description.key}" def turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" diff --git a/homeassistant/components/withings/calendar.py b/homeassistant/components/withings/calendar.py index 19572682d1a..132f00936f3 100644 --- a/homeassistant/components/withings/calendar.py +++ b/homeassistant/components/withings/calendar.py @@ -66,7 +66,7 @@ def get_event_name(category: WorkoutCategory) -> str: class WithingsWorkoutCalendarEntity( - CalendarEntity, WithingsEntity[WithingsWorkoutDataUpdateCoordinator] + WithingsEntity[WithingsWorkoutDataUpdateCoordinator], CalendarEntity ): """A calendar entity.""" diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index d43ae7da50c..fe5704d119c 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "loggers": ["aiowithings"], "quality_scale": "platinum", - "requirements": ["aiowithings==1.0.2"] + "requirements": ["aiowithings==2.0.0"] } diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 707059a2930..36ac9ea7d73 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -58,20 +58,13 @@ from .coordinator import ( from .entity import WithingsEntity -@dataclass -class WithingsMeasurementSensorEntityDescriptionMixin: - """Mixin for describing withings data.""" +@dataclass(kw_only=True) +class WithingsMeasurementSensorEntityDescription(SensorEntityDescription): + """Immutable class for describing withings data.""" measurement_type: MeasurementType -@dataclass -class WithingsMeasurementSensorEntityDescription( - SensorEntityDescription, WithingsMeasurementSensorEntityDescriptionMixin -): - """Immutable class for describing withings data.""" - - MEASUREMENT_SENSORS: dict[ MeasurementType, WithingsMeasurementSensorEntityDescription ] = { @@ -243,20 +236,13 @@ MEASUREMENT_SENSORS: dict[ } -@dataclass -class WithingsSleepSensorEntityDescriptionMixin: - """Mixin for describing withings data.""" +@dataclass(kw_only=True) +class WithingsSleepSensorEntityDescription(SensorEntityDescription): + """Immutable class for describing withings data.""" value_fn: Callable[[SleepSummary], StateType] -@dataclass -class WithingsSleepSensorEntityDescription( - SensorEntityDescription, WithingsSleepSensorEntityDescriptionMixin -): - """Immutable class for describing withings data.""" - - SLEEP_SENSORS = [ WithingsSleepSensorEntityDescription( key="sleep_breathing_disturbances_intensity", @@ -410,20 +396,13 @@ SLEEP_SENSORS = [ ] -@dataclass -class WithingsActivitySensorEntityDescriptionMixin: - """Mixin for describing withings data.""" +@dataclass(kw_only=True) +class WithingsActivitySensorEntityDescription(SensorEntityDescription): + """Immutable class for describing withings data.""" value_fn: Callable[[Activity], StateType] -@dataclass -class WithingsActivitySensorEntityDescription( - SensorEntityDescription, WithingsActivitySensorEntityDescriptionMixin -): - """Immutable class for describing withings data.""" - - ACTIVITY_SENSORS = [ WithingsActivitySensorEntityDescription( key="activity_steps_today", @@ -445,10 +424,11 @@ ACTIVITY_SENSORS = [ ), WithingsActivitySensorEntityDescription( key="activity_floors_climbed_today", - value_fn=lambda activity: activity.floors_climbed, - translation_key="activity_floors_climbed_today", + value_fn=lambda activity: activity.elevation, + translation_key="activity_elevation_today", icon="mdi:stairs-up", - native_unit_of_measurement="floors", + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.TOTAL, ), WithingsActivitySensorEntityDescription( @@ -514,20 +494,13 @@ SLEEP_GOAL = "sleep" WEIGHT_GOAL = "weight" -@dataclass -class WithingsGoalsSensorEntityDescriptionMixin: - """Mixin for describing withings data.""" +@dataclass(kw_only=True) +class WithingsGoalsSensorEntityDescription(SensorEntityDescription): + """Immutable class for describing withings data.""" value_fn: Callable[[Goals], StateType] -@dataclass -class WithingsGoalsSensorEntityDescription( - SensorEntityDescription, WithingsGoalsSensorEntityDescriptionMixin -): - """Immutable class for describing withings data.""" - - GOALS_SENSORS: dict[str, WithingsGoalsSensorEntityDescription] = { STEP_GOAL: WithingsGoalsSensorEntityDescription( key="step_goal", @@ -558,20 +531,13 @@ GOALS_SENSORS: dict[str, WithingsGoalsSensorEntityDescription] = { } -@dataclass -class WithingsWorkoutSensorEntityDescriptionMixin: - """Mixin for describing withings data.""" +@dataclass(kw_only=True) +class WithingsWorkoutSensorEntityDescription(SensorEntityDescription): + """Immutable class for describing withings data.""" value_fn: Callable[[Workout], StateType] -@dataclass -class WithingsWorkoutSensorEntityDescription( - SensorEntityDescription, WithingsWorkoutSensorEntityDescriptionMixin -): - """Immutable class for describing withings data.""" - - _WORKOUT_CATEGORY = [ workout_category.name.lower() for workout_category in WorkoutCategory ] @@ -603,10 +569,11 @@ WORKOUT_SENSORS = [ ), WithingsWorkoutSensorEntityDescription( key="workout_floors_climbed", - value_fn=lambda workout: workout.floors_climbed, - translation_key="workout_floors_climbed", + value_fn=lambda workout: workout.elevation, + translation_key="workout_elevation", icon="mdi:stairs-up", - native_unit_of_measurement="floors", + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, ), WithingsWorkoutSensorEntityDescription( key="workout_intensity", diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index fb447f3578e..ffbbd9acc2b 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -17,7 +17,10 @@ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "already_configured": "Configuration updated for profile.", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]" + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "Successfully authenticated with Withings." @@ -155,8 +158,8 @@ "activity_distance_today": { "name": "Distance travelled today" }, - "activity_floors_climbed_today": { - "name": "Floors climbed today" + "activity_elevation_today": { + "name": "Elevation change today" }, "activity_soft_duration_today": { "name": "Soft activity today" @@ -236,8 +239,8 @@ "workout_distance": { "name": "Distance travelled last workout" }, - "workout_floors_climbed": { - "name": "Floors climbed last workout" + "workout_elevation": { + "name": "Elevation change last workout" }, "workout_intensity": { "name": "Last workout intensity" diff --git a/homeassistant/components/wiz/discovery.py b/homeassistant/components/wiz/discovery.py index 0f4be1d873e..350ddfe278a 100644 --- a/homeassistant/components/wiz/discovery.py +++ b/homeassistant/components/wiz/discovery.py @@ -33,6 +33,8 @@ async def async_discover_devices( if isinstance(discovered, Exception): _LOGGER.debug("Scanning %s failed with error: %s", targets[idx], discovered) continue + if isinstance(discovered, BaseException): + raise discovered from None for device in discovered: assert isinstance(device, DiscoveredBulb) combined_discoveries[device.ip_address] = device diff --git a/homeassistant/components/wiz/number.py b/homeassistant/components/wiz/number.py index f1212c75f25..76c4b197534 100644 --- a/homeassistant/components/wiz/number.py +++ b/homeassistant/components/wiz/number.py @@ -22,21 +22,14 @@ from .entity import WizEntity from .models import WizData -@dataclass -class WizNumberEntityDescriptionMixin: - """Mixin to describe a WiZ number entity.""" - - value_fn: Callable[[wizlight], int | None] - set_value_fn: Callable[[wizlight, int], Coroutine[None, None, None]] - required_feature: str - - -@dataclass -class WizNumberEntityDescription( - NumberEntityDescription, WizNumberEntityDescriptionMixin -): +@dataclass(kw_only=True) +class WizNumberEntityDescription(NumberEntityDescription): """Class to describe a WiZ number entity.""" + required_feature: str + set_value_fn: Callable[[wizlight, int], Coroutine[None, None, None]] + value_fn: Callable[[wizlight], int | None] + async def _async_set_speed(device: wizlight, speed: int) -> None: await device.set_speed(speed) diff --git a/homeassistant/components/wled/number.py b/homeassistant/components/wled/number.py index 9fb18d3e113..9ab5554a6b7 100644 --- a/homeassistant/components/wled/number.py +++ b/homeassistant/components/wled/number.py @@ -39,18 +39,13 @@ async def async_setup_entry( update_segments() -@dataclass -class WLEDNumberDescriptionMixin: - """Mixin for WLED number.""" +@dataclass(kw_only=True) +class WLEDNumberEntityDescription(NumberEntityDescription): + """Class describing WLED number entities.""" value_fn: Callable[[Segment], float | None] -@dataclass -class WLEDNumberEntityDescription(NumberEntityDescription, WLEDNumberDescriptionMixin): - """Class describing WLED number entities.""" - - NUMBERS = [ WLEDNumberEntityDescription( key=ATTR_SPEED, diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index 7d1431c093b..64cc3dc2812 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -31,20 +31,12 @@ from .coordinator import WLEDDataUpdateCoordinator from .models import WLEDEntity -@dataclass -class WLEDSensorEntityDescriptionMixin: - """Mixin for required keys.""" - - value_fn: Callable[[WLEDDevice], datetime | StateType] - - -@dataclass -class WLEDSensorEntityDescription( - SensorEntityDescription, WLEDSensorEntityDescriptionMixin -): +@dataclass(kw_only=True) +class WLEDSensorEntityDescription(SensorEntityDescription): """Describes WLED sensor entity.""" exists_fn: Callable[[WLEDDevice], bool] = lambda _: True + value_fn: Callable[[WLEDDevice], datetime | StateType] SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index 61b9cc450fe..eff6dfab572 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -6,6 +6,9 @@ "description": "Set up your WLED to integrate with Home Assistant.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your WLED device." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/wolflink/__init__.py b/homeassistant/components/wolflink/__init__.py index 34df0176e29..73f49a2ad09 100644 --- a/homeassistant/components/wolflink/__init__.py +++ b/homeassistant/components/wolflink/__init__.py @@ -51,7 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: nonlocal refetch_parameters nonlocal parameters await wolf_client.update_session() - if not wolf_client.fetch_system_state_list(device_id, gateway_id): + if not await wolf_client.fetch_system_state_list(device_id, gateway_id): refetch_parameters = True raise UpdateFailed( "Could not fetch values from server because device is Offline." diff --git a/homeassistant/components/wolflink/const.py b/homeassistant/components/wolflink/const.py index ac5bbad48dc..59329ee41dd 100644 --- a/homeassistant/components/wolflink/const.py +++ b/homeassistant/components/wolflink/const.py @@ -7,6 +7,7 @@ PARAMETERS = "parameters" DEVICE_ID = "device_id" DEVICE_GATEWAY = "device_gateway" DEVICE_NAME = "device_name" +MANUFACTURER = "WOLF GmbH" STATES = { "Ein": "ein", diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py index b4d60011658..2135239b3eb 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -15,10 +15,11 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPressure, UnitOfTemperature, UnitOfTime from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import COORDINATOR, DEVICE_ID, DOMAIN, PARAMETERS, STATES +from .const import COORDINATOR, DEVICE_ID, DOMAIN, MANUFACTURER, PARAMETERS, STATES async def async_setup_entry( @@ -60,6 +61,11 @@ class WolfLinkSensor(CoordinatorEntity, SensorEntity): self._attr_name = wolf_object.name self._attr_unique_id = f"{device_id}:{wolf_object.parameter_id}" self._state = None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + configuration_url="https://www.wolf-smartset.com/", + manufacturer=MANUFACTURER, + ) @property def native_value(self): diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index 558e0aa9ecf..455f5d4618a 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -1,9 +1,10 @@ """Sensor to indicate whether the current day is a workday.""" from __future__ import annotations -from holidays import list_supported_countries +from holidays import HolidayBase, country_holidays, list_supported_countries from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LANGUAGE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -17,6 +18,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: country: str | None = entry.options.get(CONF_COUNTRY) province: str | None = entry.options.get(CONF_PROVINCE) + if country and CONF_LANGUAGE not in entry.options: + cls: HolidayBase = country_holidays(country, subdiv=province) + default_language = cls.default_language + new_options = entry.options.copy() + new_options[CONF_LANGUAGE] = default_language + hass.config_entries.async_update_entry(entry, options=new_options) + if country and country not in list_supported_countries(): async_create_issue( hass, diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 6a541cc84e1..9cc96db7a57 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -2,19 +2,25 @@ from __future__ import annotations from datetime import date, timedelta +from typing import Final from holidays import ( HolidayBase, __version__ as python_holidays_version, country_holidays, ) +import voluptuous as vol from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_LANGUAGE, CONF_NAME +from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from homeassistant.util import dt as dt_util from .const import ( @@ -30,6 +36,9 @@ from .const import ( LOGGER, ) +SERVICE_CHECK_DATE: Final = "check_date" +CHECK_DATE: Final = "check_date" + def validate_dates(holiday_list: list[str]) -> list[str]: """Validate and adds to list of dates to add or remove.""" @@ -63,17 +72,29 @@ async def async_setup_entry( province: str | None = entry.options.get(CONF_PROVINCE) sensor_name: str = entry.options[CONF_NAME] workdays: list[str] = entry.options[CONF_WORKDAYS] + language: str | None = entry.options.get(CONF_LANGUAGE) year: int = (dt_util.now() + timedelta(days=days_offset)).year if country: - cls: HolidayBase = country_holidays(country, subdiv=province, years=year) obj_holidays: HolidayBase = country_holidays( country, subdiv=province, years=year, - language=cls.default_language, + language=language, ) + if ( + supported_languages := obj_holidays.supported_languages + ) and language == "en": + for lang in supported_languages: + if lang.startswith("en"): + obj_holidays = country_holidays( + country, + subdiv=province, + years=year, + language=lang, + ) + LOGGER.debug("Changing language from %s to %s", language, lang) else: obj_holidays = HolidayBase() @@ -109,6 +130,15 @@ async def async_setup_entry( _holiday_string = holiday_date.strftime("%Y-%m-%d") LOGGER.debug("%s %s", _holiday_string, name) + platform = async_get_current_platform() + platform.async_register_entity_service( + SERVICE_CHECK_DATE, + {vol.Required(CHECK_DATE): cv.date}, + "check_date", + None, + SupportsResponse.ONLY, + ) + async_add_entities( [ IsWorkdaySensor( @@ -129,6 +159,7 @@ class IsWorkdaySensor(BinarySensorEntity): _attr_has_entity_name = True _attr_name = None + _attr_translation_key = DOMAIN def __init__( self, @@ -191,3 +222,8 @@ class IsWorkdaySensor(BinarySensorEntity): if self.is_exclude(day_of_week, adjusted_date): self._attr_is_on = False + + async def check_date(self, check_date: date) -> ServiceResponse: + """Check if date is workday or not.""" + holiday_date = check_date in self._obj_holidays + return {"workday": not holiday_date} diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 907f5c5bdb5..348bb0c2fba 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -11,13 +11,15 @@ from homeassistant.config_entries import ( ConfigFlow, OptionsFlowWithConfigEntry, ) -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_LANGUAGE, CONF_NAME from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.selector import ( CountrySelector, CountrySelectorConfig, + LanguageSelector, + LanguageSelectorConfig, NumberSelector, NumberSelectorConfig, NumberSelectorMode, @@ -46,7 +48,7 @@ from .const import ( ) -def add_province_to_schema( +def add_province_and_language_to_schema( schema: vol.Schema, country: str | None, ) -> vol.Schema: @@ -54,21 +56,37 @@ def add_province_to_schema( if not country: return schema - all_countries = list_supported_countries() - if not all_countries.get(country): - return schema + all_countries = list_supported_countries(include_aliases=False) - add_schema = { - vol.Optional(CONF_PROVINCE): SelectSelector( - SelectSelectorConfig( - options=all_countries[country], - mode=SelectSelectorMode.DROPDOWN, - translation_key=CONF_PROVINCE, + language_schema = {} + province_schema = {} + + _country = country_holidays(country=country) + if country_default_language := (_country.default_language): + selectable_languages = _country.supported_languages + new_selectable_languages = [] + for lang in selectable_languages: + new_selectable_languages.append(lang[:2]) + language_schema = { + vol.Optional( + CONF_LANGUAGE, default=country_default_language + ): LanguageSelector( + LanguageSelectorConfig(languages=new_selectable_languages) ) - ), - } + } - return vol.Schema({**DATA_SCHEMA_OPT.schema, **add_schema}) + if provinces := all_countries.get(country): + province_schema = { + vol.Optional(CONF_PROVINCE): SelectSelector( + SelectSelectorConfig( + options=provinces, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_PROVINCE, + ) + ), + } + + return vol.Schema({**DATA_SCHEMA_OPT.schema, **language_schema, **province_schema}) def _is_valid_date_range(check_date: str, error: type[HomeAssistantError]) -> bool: @@ -93,13 +111,25 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: year: int = dt_util.now().year if country := user_input.get(CONF_COUNTRY): - cls = country_holidays(country) + language = user_input.get(CONF_LANGUAGE) + province = user_input.get(CONF_PROVINCE) obj_holidays = country_holidays( country=country, - subdiv=user_input.get(CONF_PROVINCE), + subdiv=province, years=year, - language=cls.default_language, + language=language, ) + if ( + supported_languages := obj_holidays.supported_languages + ) and language == "en": + for lang in supported_languages: + if lang.startswith("en"): + obj_holidays = country_holidays( + country, + subdiv=province, + years=year, + language=lang, + ) else: obj_holidays = HolidayBase(years=year) @@ -117,7 +147,7 @@ DATA_SCHEMA_SETUP = vol.Schema( vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), vol.Optional(CONF_COUNTRY): CountrySelector( CountrySelectorConfig( - countries=list(list_supported_countries()), + countries=list(list_supported_countries(include_aliases=False)), ) ), } @@ -237,7 +267,9 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): ) schema = await self.hass.async_add_executor_job( - add_province_to_schema, DATA_SCHEMA_OPT, self.data.get(CONF_COUNTRY) + add_province_and_language_to_schema, + DATA_SCHEMA_OPT, + self.data.get(CONF_COUNTRY), ) new_schema = self.add_suggested_values_to_schema(schema, user_input) return self.async_show_form( @@ -298,7 +330,9 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): return self.async_create_entry(data=combined_input) schema: vol.Schema = await self.hass.async_add_executor_job( - add_province_to_schema, DATA_SCHEMA_OPT, self.options.get(CONF_COUNTRY) + add_province_and_language_to_schema, + DATA_SCHEMA_OPT, + self.options.get(CONF_COUNTRY), ) new_schema = self.add_suggested_values_to_schema( diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 1c9a533d998..c7c993e70d0 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.35"] + "requirements": ["holidays==0.36"] } diff --git a/homeassistant/components/workday/repairs.py b/homeassistant/components/workday/repairs.py index daafd0396b8..fbed179763e 100644 --- a/homeassistant/components/workday/repairs.py +++ b/homeassistant/components/workday/repairs.py @@ -43,7 +43,7 @@ class CountryFixFlow(RepairsFlow): ) -> data_entry_flow.FlowResult: """Handle the country step of a fix flow.""" if user_input is not None: - all_countries = list_supported_countries() + all_countries = list_supported_countries(include_aliases=False) if not all_countries[user_input[CONF_COUNTRY]]: options = dict(self.entry.options) new_options = {**options, **user_input, CONF_PROVINCE: None} @@ -61,7 +61,9 @@ class CountryFixFlow(RepairsFlow): { vol.Required(CONF_COUNTRY): SelectSelector( SelectSelectorConfig( - options=sorted(list_supported_countries()), + options=sorted( + list_supported_countries(include_aliases=False) + ), mode=SelectSelectorMode.DROPDOWN, ) ) @@ -83,7 +85,9 @@ class CountryFixFlow(RepairsFlow): return self.async_create_entry(data={}) assert self.country - country_provinces = list_supported_countries()[self.country] + country_provinces = list_supported_countries(include_aliases=False)[ + self.country + ] return self.async_show_form( step_id="province", data_schema=vol.Schema( diff --git a/homeassistant/components/workday/services.yaml b/homeassistant/components/workday/services.yaml new file mode 100644 index 00000000000..00935cd7215 --- /dev/null +++ b/homeassistant/components/workday/services.yaml @@ -0,0 +1,9 @@ +check_date: + target: + entity: + integration: workday + fields: + check_date: + example: "2022-12-25" + selector: + date: diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index d0ffecd0f7e..20e7cd26fd6 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -19,7 +19,8 @@ "workdays": "Workdays", "add_holidays": "Add holidays", "remove_holidays": "Remove Holidays", - "province": "Subdivision of country" + "province": "Subdivision of country", + "language": "Language for named holidays" }, "data_description": { "excludes": "List of workdays to exclude", @@ -27,7 +28,8 @@ "workdays": "List of 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, Region of Country" + "province": "State, Territory, Province, Region of Country", + "language": "Choose the language you want to configure named holidays after" } } }, @@ -48,7 +50,8 @@ "workdays": "[%key:component::workday::config::step::options::data::workdays%]", "add_holidays": "[%key:component::workday::config::step::options::data::add_holidays%]", "remove_holidays": "[%key:component::workday::config::step::options::data::remove_holidays%]", - "province": "[%key:component::workday::config::step::options::data::province%]" + "province": "[%key:component::workday::config::step::options::data::province%]", + "language": "[%key:component::workday::config::step::options::data::language%]" }, "data_description": { "excludes": "[%key:component::workday::config::step::options::data_description::excludes%]", @@ -56,7 +59,8 @@ "workdays": "[%key:component::workday::config::step::options::data_description::workdays%]", "add_holidays": "[%key:component::workday::config::step::options::data_description::add_holidays%]", "remove_holidays": "[%key:component::workday::config::step::options::data_description::remove_holidays%]", - "province": "[%key:component::workday::config::step::options::data_description::province%]" + "province": "[%key:component::workday::config::step::options::data_description::province%]", + "language": "[%key:component::workday::config::step::options::data_description::language%]" } } }, @@ -129,5 +133,34 @@ } } } + }, + "entity": { + "binary_sensor": { + "workday": { + "state_attributes": { + "workdays": { + "name": "[%key:component::workday::config::step::options::data::workdays%]" + }, + "excludes": { + "name": "[%key:component::workday::config::step::options::data::excludes%]" + }, + "days_offset": { + "name": "[%key:component::workday::config::step::options::data::days_offset%]" + } + } + } + } + }, + "services": { + "check_date": { + "name": "Check date", + "description": "Check if date is workday.", + "fields": { + "check_date": { + "name": "Date", + "description": "Date to check if workday." + } + } + } } } diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py index 33064d21097..2cc9b7050a0 100644 --- a/homeassistant/components/wyoming/__init__.py +++ b/homeassistant/components/wyoming/__init__.py @@ -4,17 +4,26 @@ from __future__ import annotations import logging from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from .const import ATTR_SPEAKER, DOMAIN from .data import WyomingService +from .devices import SatelliteDevice +from .models import DomainDataItem +from .satellite import WyomingSatellite _LOGGER = logging.getLogger(__name__) +SATELLITE_PLATFORMS = [Platform.BINARY_SENSOR, Platform.SELECT, Platform.SWITCH] + __all__ = [ "ATTR_SPEAKER", "DOMAIN", + "async_setup_entry", + "async_unload_entry", ] @@ -25,24 +34,72 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if service is None: raise ConfigEntryNotReady("Unable to connect") - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = service + item = DomainDataItem(service=service) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = item - await hass.config_entries.async_forward_entry_setups( - entry, - service.platforms, - ) + await hass.config_entries.async_forward_entry_setups(entry, service.platforms) + entry.async_on_unload(entry.add_update_listener(update_listener)) + + if (satellite_info := service.info.satellite) is not None: + # Create satellite device, etc. + item.satellite = _make_satellite(hass, entry, service) + + # Set up satellite sensors, switches, etc. + await hass.config_entries.async_forward_entry_setups(entry, SATELLITE_PLATFORMS) + + # Start satellite communication + entry.async_create_background_task( + hass, + item.satellite.run(), + f"Satellite {satellite_info.name}", + ) + + entry.async_on_unload(item.satellite.stop) return True +def _make_satellite( + hass: HomeAssistant, config_entry: ConfigEntry, service: WyomingService +) -> WyomingSatellite: + """Create Wyoming satellite/device from config entry and Wyoming service.""" + satellite_info = service.info.satellite + assert satellite_info is not None + + dev_reg = dr.async_get(hass) + + # Use config entry id since only one satellite per entry is supported + satellite_id = config_entry.entry_id + + device = dev_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, satellite_id)}, + name=satellite_info.name, + suggested_area=satellite_info.area, + ) + + satellite_device = SatelliteDevice( + satellite_id=satellite_id, + device_id=device.id, + ) + + return WyomingSatellite(hass, service, satellite_device) + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Wyoming.""" - service: WyomingService = hass.data[DOMAIN][entry.entry_id] + item: DomainDataItem = hass.data[DOMAIN][entry.entry_id] - unload_ok = await hass.config_entries.async_unload_platforms( - entry, - service.platforms, - ) + platforms = list(item.service.platforms) + if item.satellite is not None: + platforms += SATELLITE_PLATFORMS + + unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms) if unload_ok: del hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wyoming/binary_sensor.py b/homeassistant/components/wyoming/binary_sensor.py new file mode 100644 index 00000000000..4f2c0bb170a --- /dev/null +++ b/homeassistant/components/wyoming/binary_sensor.py @@ -0,0 +1,55 @@ +"""Binary sensor for Wyoming.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary sensor entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + async_add_entities([WyomingSatelliteAssistInProgress(item.satellite.device)]) + + +class WyomingSatelliteAssistInProgress(WyomingSatelliteEntity, BinarySensorEntity): + """Entity to represent Assist is in progress for satellite.""" + + entity_description = BinarySensorEntityDescription( + key="assist_in_progress", + translation_key="assist_in_progress", + ) + _attr_is_on = False + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + + self._device.set_is_active_listener(self._is_active_changed) + + @callback + def _is_active_changed(self) -> None: + """Call when active state changed.""" + self._attr_is_on = self._device.is_active + self.async_write_ha_state() diff --git a/homeassistant/components/wyoming/config_flow.py b/homeassistant/components/wyoming/config_flow.py index f6b8ed73890..b766fc80c89 100644 --- a/homeassistant/components/wyoming/config_flow.py +++ b/homeassistant/components/wyoming/config_flow.py @@ -1,19 +1,22 @@ """Config flow for Wyoming integration.""" from __future__ import annotations +import logging from typing import Any from urllib.parse import urlparse import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.hassio import HassioServiceInfo -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.components import hassio, zeroconf +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN from .data import WyomingService +_LOGGER = logging.getLogger() + STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, @@ -27,7 +30,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - _hassio_discovery: HassioServiceInfo + _hassio_discovery: hassio.HassioServiceInfo + _service: WyomingService | None = None + _name: str | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -50,27 +55,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors={"base": "cannot_connect"}, ) - # ASR = automated speech recognition (speech-to-text) - asr_installed = [asr for asr in service.info.asr if asr.installed] + if name := service.get_name(): + return self.async_create_entry(title=name, data=user_input) - # TTS = text-to-speech - tts_installed = [tts for tts in service.info.tts if tts.installed] + return self.async_abort(reason="no_services") - # wake-word-detection - wake_installed = [wake for wake in service.info.wake if wake.installed] - - if asr_installed: - name = asr_installed[0].name - elif tts_installed: - name = tts_installed[0].name - elif wake_installed: - name = wake_installed[0].name - else: - return self.async_abort(reason="no_services") - - return self.async_create_entry(title=name, data=user_input) - - async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: + async def async_step_hassio( + self, discovery_info: hassio.HassioServiceInfo + ) -> FlowResult: """Handle Supervisor add-on discovery.""" await self.async_set_unique_id(discovery_info.uuid) self._abort_if_unique_id_configured() @@ -93,11 +85,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: uri = urlparse(self._hassio_discovery.config["uri"]) if service := await WyomingService.create(uri.hostname, uri.port): - if ( - not any(asr for asr in service.info.asr if asr.installed) - and not any(tts for tts in service.info.tts if tts.installed) - and not any(wake for wake in service.info.wake if wake.installed) - ): + if not service.has_services(): return self.async_abort(reason="no_services") return self.async_create_entry( @@ -112,3 +100,52 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders={"addon": self._hassio_discovery.name}, errors=errors, ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + _LOGGER.debug("Discovery info: %s", discovery_info) + if discovery_info.port is None: + return self.async_abort(reason="no_port") + + service = await WyomingService.create(discovery_info.host, discovery_info.port) + if (service is None) or (not (name := service.get_name())): + # No supported services + return self.async_abort(reason="no_services") + + self._name = name + + # Use zeroconf name + service name as unique id. + # The satellite will use its own MAC as the zeroconf name by default. + unique_id = f"{discovery_info.name}_{self._name}" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + self.context[CONF_NAME] = self._name + self.context["title_placeholders"] = {"name": self._name} + + self._service = service + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by zeroconf.""" + assert self._service is not None + assert self._name is not None + + if user_input is None: + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"name": self._name}, + errors={}, + ) + + return self.async_create_entry( + title=self._name, + data={ + CONF_HOST: self._service.host, + CONF_PORT: self._service.port, + }, + ) diff --git a/homeassistant/components/wyoming/data.py b/homeassistant/components/wyoming/data.py index 64b92eb8471..ea58181a707 100644 --- a/homeassistant/components/wyoming/data.py +++ b/homeassistant/components/wyoming/data.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from wyoming.client import AsyncTcpClient -from wyoming.info import Describe, Info +from wyoming.info import Describe, Info, Satellite from homeassistant.const import Platform @@ -32,6 +32,43 @@ class WyomingService: platforms.append(Platform.WAKE_WORD) self.platforms = platforms + def has_services(self) -> bool: + """Return True if services are installed that Home Assistant can use.""" + return ( + any(asr for asr in self.info.asr if asr.installed) + or any(tts for tts in self.info.tts if tts.installed) + or any(wake for wake in self.info.wake if wake.installed) + or ((self.info.satellite is not None) and self.info.satellite.installed) + ) + + def get_name(self) -> str | None: + """Return name of first installed usable service.""" + # ASR = automated speech recognition (speech-to-text) + asr_installed = [asr for asr in self.info.asr if asr.installed] + if asr_installed: + return asr_installed[0].name + + # TTS = text-to-speech + tts_installed = [tts for tts in self.info.tts if tts.installed] + if tts_installed: + return tts_installed[0].name + + # wake-word-detection + wake_installed = [wake for wake in self.info.wake if wake.installed] + if wake_installed: + return wake_installed[0].name + + # satellite + satellite_installed: Satellite | None = None + + if (self.info.satellite is not None) and self.info.satellite.installed: + satellite_installed = self.info.satellite + + if satellite_installed: + return satellite_installed.name + + return None + @classmethod async def create(cls, host: str, port: int) -> WyomingService | None: """Create a Wyoming service.""" diff --git a/homeassistant/components/wyoming/devices.py b/homeassistant/components/wyoming/devices.py new file mode 100644 index 00000000000..90dad889707 --- /dev/null +++ b/homeassistant/components/wyoming/devices.py @@ -0,0 +1,85 @@ +"""Class to manage satellite devices.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er + +from .const import DOMAIN + + +@dataclass +class SatelliteDevice: + """Class to store device.""" + + satellite_id: str + device_id: str + is_active: bool = False + is_enabled: bool = True + pipeline_name: str | None = None + + _is_active_listener: Callable[[], None] | None = None + _is_enabled_listener: Callable[[], None] | None = None + _pipeline_listener: Callable[[], None] | None = None + + @callback + def set_is_active(self, active: bool) -> None: + """Set active state.""" + if active != self.is_active: + self.is_active = active + if self._is_active_listener is not None: + self._is_active_listener() + + @callback + def set_is_enabled(self, enabled: bool) -> None: + """Set enabled state.""" + if enabled != self.is_enabled: + self.is_enabled = enabled + if self._is_enabled_listener is not None: + self._is_enabled_listener() + + @callback + def set_pipeline_name(self, pipeline_name: str) -> None: + """Inform listeners that pipeline selection has changed.""" + if pipeline_name != self.pipeline_name: + self.pipeline_name = pipeline_name + if self._pipeline_listener is not None: + self._pipeline_listener() + + @callback + def set_is_active_listener(self, is_active_listener: Callable[[], None]) -> None: + """Listen for updates to is_active.""" + self._is_active_listener = is_active_listener + + @callback + def set_is_enabled_listener(self, is_enabled_listener: Callable[[], None]) -> None: + """Listen for updates to is_enabled.""" + self._is_enabled_listener = is_enabled_listener + + @callback + def set_pipeline_listener(self, pipeline_listener: Callable[[], None]) -> None: + """Listen for updates to pipeline.""" + self._pipeline_listener = pipeline_listener + + def get_assist_in_progress_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for assist in progress binary sensor.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "binary_sensor", DOMAIN, f"{self.satellite_id}-assist_in_progress" + ) + + def get_satellite_enabled_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for satellite enabled switch.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "switch", DOMAIN, f"{self.satellite_id}-satellite_enabled" + ) + + def get_pipeline_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for pipeline select.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "select", DOMAIN, f"{self.satellite_id}-pipeline" + ) diff --git a/homeassistant/components/wyoming/entity.py b/homeassistant/components/wyoming/entity.py new file mode 100644 index 00000000000..5ed890bc60e --- /dev/null +++ b/homeassistant/components/wyoming/entity.py @@ -0,0 +1,24 @@ +"""Wyoming entities.""" + +from __future__ import annotations + +from homeassistant.helpers import entity +from homeassistant.helpers.device_registry import DeviceInfo + +from .const import DOMAIN +from .satellite import SatelliteDevice + + +class WyomingSatelliteEntity(entity.Entity): + """Wyoming satellite entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, device: SatelliteDevice) -> None: + """Initialize entity.""" + self._device = device + self._attr_unique_id = f"{device.satellite_id}-{self.entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.satellite_id)}, + ) diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index ddb5407e1ce..540aaa9aeac 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -3,7 +3,9 @@ "name": "Wyoming Protocol", "codeowners": ["@balloob", "@synesthesiam"], "config_flow": true, + "dependencies": ["assist_pipeline"], "documentation": "https://www.home-assistant.io/integrations/wyoming", "iot_class": "local_push", - "requirements": ["wyoming==1.2.0"] + "requirements": ["wyoming==1.3.0"], + "zeroconf": ["_wyoming._tcp.local."] } diff --git a/homeassistant/components/wyoming/models.py b/homeassistant/components/wyoming/models.py new file mode 100644 index 00000000000..dce45d509eb --- /dev/null +++ b/homeassistant/components/wyoming/models.py @@ -0,0 +1,13 @@ +"""Models for wyoming.""" +from dataclasses import dataclass + +from .data import WyomingService +from .satellite import WyomingSatellite + + +@dataclass +class DomainDataItem: + """Domain data item.""" + + service: WyomingService + satellite: WyomingSatellite | None = None diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py new file mode 100644 index 00000000000..caf65db115e --- /dev/null +++ b/homeassistant/components/wyoming/satellite.py @@ -0,0 +1,380 @@ +"""Support for Wyoming satellite services.""" +import asyncio +from collections.abc import AsyncGenerator +import io +import logging +from typing import Final +import wave + +from wyoming.asr import Transcribe, Transcript +from wyoming.audio import AudioChunk, AudioChunkConverter, AudioStart, AudioStop +from wyoming.client import AsyncTcpClient +from wyoming.pipeline import PipelineStage, RunPipeline +from wyoming.satellite import RunSatellite +from wyoming.tts import Synthesize, SynthesizeVoice +from wyoming.vad import VoiceStarted, VoiceStopped +from wyoming.wake import Detect, Detection + +from homeassistant.components import assist_pipeline, stt, tts +from homeassistant.components.assist_pipeline import select as pipeline_select +from homeassistant.core import Context, HomeAssistant + +from .const import DOMAIN +from .data import WyomingService +from .devices import SatelliteDevice + +_LOGGER = logging.getLogger() + +_SAMPLES_PER_CHUNK: Final = 1024 +_RECONNECT_SECONDS: Final = 10 +_RESTART_SECONDS: Final = 3 + +# Wyoming stage -> Assist stage +_STAGES: dict[PipelineStage, assist_pipeline.PipelineStage] = { + PipelineStage.WAKE: assist_pipeline.PipelineStage.WAKE_WORD, + PipelineStage.ASR: assist_pipeline.PipelineStage.STT, + PipelineStage.HANDLE: assist_pipeline.PipelineStage.INTENT, + PipelineStage.TTS: assist_pipeline.PipelineStage.TTS, +} + + +class WyomingSatellite: + """Remove voice satellite running the Wyoming protocol.""" + + def __init__( + self, hass: HomeAssistant, service: WyomingService, device: SatelliteDevice + ) -> None: + """Initialize satellite.""" + self.hass = hass + self.service = service + self.device = device + self.is_enabled = True + self.is_running = True + + self._client: AsyncTcpClient | None = None + self._chunk_converter = AudioChunkConverter(rate=16000, width=2, channels=1) + self._is_pipeline_running = False + self._audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue() + self._pipeline_id: str | None = None + self._enabled_changed_event = asyncio.Event() + + self.device.set_is_enabled_listener(self._enabled_changed) + self.device.set_pipeline_listener(self._pipeline_changed) + + async def run(self) -> None: + """Run and maintain a connection to satellite.""" + _LOGGER.debug("Running satellite task") + + try: + while self.is_running: + try: + # Check if satellite has been disabled + if not self.device.is_enabled: + await self.on_disabled() + if not self.is_running: + # Satellite was stopped while waiting to be enabled + break + + # Connect and run pipeline loop + await self._run_once() + except asyncio.CancelledError: + raise + except Exception: # pylint: disable=broad-exception-caught + await self.on_restart() + finally: + # Ensure sensor is off + self.device.set_is_active(False) + + await self.on_stopped() + + def stop(self) -> None: + """Signal satellite task to stop running.""" + self.is_running = False + + # Unblock waiting for enabled + self._enabled_changed_event.set() + + async def on_restart(self) -> None: + """Block until pipeline loop will be restarted.""" + _LOGGER.warning( + "Unexpected error running satellite. Restarting in %s second(s)", + _RECONNECT_SECONDS, + ) + await asyncio.sleep(_RESTART_SECONDS) + + async def on_reconnect(self) -> None: + """Block until a reconnection attempt should be made.""" + _LOGGER.debug( + "Failed to connect to satellite. Reconnecting in %s second(s)", + _RECONNECT_SECONDS, + ) + await asyncio.sleep(_RECONNECT_SECONDS) + + async def on_disabled(self) -> None: + """Block until device may be enabled again.""" + await self._enabled_changed_event.wait() + + async def on_stopped(self) -> None: + """Run when run() has fully stopped.""" + _LOGGER.debug("Satellite task stopped") + + # ------------------------------------------------------------------------- + + def _enabled_changed(self) -> None: + """Run when device enabled status changes.""" + + if not self.device.is_enabled: + # Cancel any running pipeline + self._audio_queue.put_nowait(None) + + self._enabled_changed_event.set() + + def _pipeline_changed(self) -> None: + """Run when device pipeline changes.""" + + # Cancel any running pipeline + self._audio_queue.put_nowait(None) + + async def _run_once(self) -> None: + """Run pipelines until an error occurs.""" + self.device.set_is_active(False) + + while self.is_running and self.is_enabled: + try: + await self._connect() + break + except ConnectionError: + await self.on_reconnect() + + assert self._client is not None + _LOGGER.debug("Connected to satellite") + + if (not self.is_running) or (not self.is_enabled): + # Run was cancelled or satellite was disabled during connection + return + + # Tell satellite that we're ready + await self._client.write_event(RunSatellite().event()) + + # Wait until we get RunPipeline event + run_pipeline: RunPipeline | None = None + while self.is_running and self.is_enabled: + run_event = await self._client.read_event() + if run_event is None: + raise ConnectionResetError("Satellite disconnected") + + if RunPipeline.is_type(run_event.type): + run_pipeline = RunPipeline.from_event(run_event) + break + + _LOGGER.debug("Unexpected event from satellite: %s", run_event) + + assert run_pipeline is not None + _LOGGER.debug("Received run information: %s", run_pipeline) + + if (not self.is_running) or (not self.is_enabled): + # Run was cancelled or satellite was disabled while waiting for + # RunPipeline event. + return + + start_stage = _STAGES.get(run_pipeline.start_stage) + end_stage = _STAGES.get(run_pipeline.end_stage) + + if start_stage is None: + raise ValueError(f"Invalid start stage: {start_stage}") + + if end_stage is None: + raise ValueError(f"Invalid end stage: {end_stage}") + + # Each loop is a pipeline run + while self.is_running and self.is_enabled: + # Use select to get pipeline each time in case it's changed + pipeline_id = pipeline_select.get_chosen_pipeline( + self.hass, + DOMAIN, + self.device.satellite_id, + ) + pipeline = assist_pipeline.async_get_pipeline(self.hass, pipeline_id) + assert pipeline is not None + + # We will push audio in through a queue + self._audio_queue = asyncio.Queue() + stt_stream = self._stt_stream() + + # Start pipeline running + _LOGGER.debug( + "Starting pipeline %s from %s to %s", + pipeline.name, + start_stage, + end_stage, + ) + self._is_pipeline_running = True + _pipeline_task = asyncio.create_task( + assist_pipeline.async_pipeline_from_audio_stream( + self.hass, + context=Context(), + event_callback=self._event_callback, + stt_metadata=stt.SpeechMetadata( + language=pipeline.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=stt_stream, + start_stage=start_stage, + end_stage=end_stage, + tts_audio_output="wav", + pipeline_id=pipeline_id, + ) + ) + + # Run until pipeline is complete or cancelled with an empty audio chunk + while self._is_pipeline_running: + client_event = await self._client.read_event() + if client_event is None: + raise ConnectionResetError("Satellite disconnected") + + if AudioChunk.is_type(client_event.type): + # Microphone audio + chunk = AudioChunk.from_event(client_event) + chunk = self._chunk_converter.convert(chunk) + self._audio_queue.put_nowait(chunk.audio) + else: + _LOGGER.debug("Unexpected event from satellite: %s", client_event) + + _LOGGER.debug("Pipeline finished") + + def _event_callback(self, event: assist_pipeline.PipelineEvent) -> None: + """Translate pipeline events into Wyoming events.""" + assert self._client is not None + + if event.type == assist_pipeline.PipelineEventType.RUN_END: + # Pipeline run is complete + self._is_pipeline_running = False + self.device.set_is_active(False) + elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_START: + self.hass.add_job(self._client.write_event(Detect().event())) + elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_END: + # Wake word detection + self.device.set_is_active(True) + + # Inform client of wake word detection + if event.data and (wake_word_output := event.data.get("wake_word_output")): + detection = Detection( + name=wake_word_output["wake_word_id"], + timestamp=wake_word_output.get("timestamp"), + ) + self.hass.add_job(self._client.write_event(detection.event())) + elif event.type == assist_pipeline.PipelineEventType.STT_START: + # Speech-to-text + self.device.set_is_active(True) + + if event.data: + self.hass.add_job( + self._client.write_event( + Transcribe(language=event.data["metadata"]["language"]).event() + ) + ) + elif event.type == assist_pipeline.PipelineEventType.STT_VAD_START: + # User started speaking + if event.data: + self.hass.add_job( + self._client.write_event( + VoiceStarted(timestamp=event.data["timestamp"]).event() + ) + ) + elif event.type == assist_pipeline.PipelineEventType.STT_VAD_END: + # User stopped speaking + if event.data: + self.hass.add_job( + self._client.write_event( + VoiceStopped(timestamp=event.data["timestamp"]).event() + ) + ) + elif event.type == assist_pipeline.PipelineEventType.STT_END: + # Speech-to-text transcript + if event.data: + # Inform client of transript + stt_text = event.data["stt_output"]["text"] + self.hass.add_job( + self._client.write_event(Transcript(text=stt_text).event()) + ) + elif event.type == assist_pipeline.PipelineEventType.TTS_START: + # Text-to-speech text + if event.data: + # Inform client of text + self.hass.add_job( + self._client.write_event( + Synthesize( + text=event.data["tts_input"], + voice=SynthesizeVoice( + name=event.data.get("voice"), + language=event.data.get("language"), + ), + ).event() + ) + ) + elif event.type == assist_pipeline.PipelineEventType.TTS_END: + # TTS stream + if event.data and (tts_output := event.data["tts_output"]): + media_id = tts_output["media_id"] + self.hass.add_job(self._stream_tts(media_id)) + + async def _connect(self) -> None: + """Connect to satellite over TCP.""" + _LOGGER.debug( + "Connecting to satellite at %s:%s", self.service.host, self.service.port + ) + self._client = AsyncTcpClient(self.service.host, self.service.port) + await self._client.connect() + + async def _stream_tts(self, media_id: str) -> None: + """Stream TTS WAV audio to satellite in chunks.""" + assert self._client is not None + + extension, data = await tts.async_get_media_source_audio(self.hass, media_id) + if extension != "wav": + raise ValueError(f"Cannot stream audio format to satellite: {extension}") + + with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: + sample_rate = wav_file.getframerate() + sample_width = wav_file.getsampwidth() + sample_channels = wav_file.getnchannels() + _LOGGER.debug("Streaming %s TTS sample(s)", wav_file.getnframes()) + + timestamp = 0 + await self._client.write_event( + AudioStart( + rate=sample_rate, + width=sample_width, + channels=sample_channels, + timestamp=timestamp, + ).event() + ) + + # Stream audio chunks + while audio_bytes := wav_file.readframes(_SAMPLES_PER_CHUNK): + chunk = AudioChunk( + rate=sample_rate, + width=sample_width, + channels=sample_channels, + audio=audio_bytes, + timestamp=timestamp, + ) + await self._client.write_event(chunk.event()) + timestamp += chunk.seconds + + await self._client.write_event(AudioStop(timestamp=timestamp).event()) + _LOGGER.debug("TTS streaming complete") + + async def _stt_stream(self) -> AsyncGenerator[bytes, None]: + """Yield audio chunks from a queue.""" + is_first_chunk = True + while chunk := await self._audio_queue.get(): + if is_first_chunk: + is_first_chunk = False + _LOGGER.debug("Receiving audio from satellite") + + yield chunk diff --git a/homeassistant/components/wyoming/select.py b/homeassistant/components/wyoming/select.py new file mode 100644 index 00000000000..2929ae79fa0 --- /dev/null +++ b/homeassistant/components/wyoming/select.py @@ -0,0 +1,47 @@ +"""Select entities for VoIP integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.components.assist_pipeline.select import AssistPipelineSelect +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .devices import SatelliteDevice +from .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up VoIP switch entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + async_add_entities([WyomingSatellitePipelineSelect(hass, item.satellite.device)]) + + +class WyomingSatellitePipelineSelect(WyomingSatelliteEntity, AssistPipelineSelect): + """Pipeline selector for Wyoming satellites.""" + + def __init__(self, hass: HomeAssistant, device: SatelliteDevice) -> None: + """Initialize a pipeline selector.""" + self.device = device + + WyomingSatelliteEntity.__init__(self, device) + AssistPipelineSelect.__init__(self, hass, device.satellite_id) + + async def async_select_option(self, option: str) -> None: + """Select an option.""" + await super().async_select_option(option) + self.device.set_pipeline_name(option) diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json index 20d73d8dc13..19b6a513d4b 100644 --- a/homeassistant/components/wyoming/strings.json +++ b/homeassistant/components/wyoming/strings.json @@ -9,6 +9,10 @@ }, "hassio_confirm": { "description": "Do you want to configure Home Assistant to connect to the Wyoming service provided by the add-on: {addon}?" + }, + "zeroconf_confirm": { + "description": "Do you want to configure Home Assistant to connect to the Wyoming service {name}?", + "title": "Discovered Wyoming service" } }, "error": { @@ -16,7 +20,31 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "no_services": "No services found at endpoint" + "no_services": "No services found at endpoint", + "no_port": "No port for endpoint" + } + }, + "entity": { + "binary_sensor": { + "assist_in_progress": { + "name": "[%key:component::assist_pipeline::entity::binary_sensor::assist_in_progress::name%]" + } + }, + "select": { + "pipeline": { + "name": "[%key:component::assist_pipeline::entity::select::pipeline::name%]", + "state": { + "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" + } + }, + "noise_suppression": { + "name": "Noise suppression" + } + }, + "switch": { + "satellite_enabled": { + "name": "Satellite enabled" + } } } } diff --git a/homeassistant/components/wyoming/stt.py b/homeassistant/components/wyoming/stt.py index e64a2f14667..8a21ef051fc 100644 --- a/homeassistant/components/wyoming/stt.py +++ b/homeassistant/components/wyoming/stt.py @@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, SAMPLE_CHANNELS, SAMPLE_RATE, SAMPLE_WIDTH from .data import WyomingService from .error import WyomingError +from .models import DomainDataItem _LOGGER = logging.getLogger(__name__) @@ -24,10 +25,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Wyoming speech-to-text.""" - service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ - WyomingSttProvider(config_entry, service), + WyomingSttProvider(config_entry, item.service), ] ) diff --git a/homeassistant/components/wyoming/switch.py b/homeassistant/components/wyoming/switch.py new file mode 100644 index 00000000000..2bc43122588 --- /dev/null +++ b/homeassistant/components/wyoming/switch.py @@ -0,0 +1,65 @@ +"""Wyoming switch entities.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers import restore_state +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up VoIP switch entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + async_add_entities([WyomingSatelliteEnabledSwitch(item.satellite.device)]) + + +class WyomingSatelliteEnabledSwitch( + WyomingSatelliteEntity, restore_state.RestoreEntity, SwitchEntity +): + """Entity to represent if satellite is enabled.""" + + entity_description = SwitchEntityDescription( + key="satellite_enabled", + translation_key="satellite_enabled", + entity_category=EntityCategory.CONFIG, + ) + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + + # Default to on + self._attr_is_on = (state is None) or (state.state == STATE_ON) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on.""" + self._attr_is_on = True + self.async_write_ha_state() + self._device.set_is_enabled(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off.""" + self._attr_is_on = False + self.async_write_ha_state() + self._device.set_is_enabled(False) diff --git a/homeassistant/components/wyoming/tts.py b/homeassistant/components/wyoming/tts.py index 6510fd8c761..f024f925514 100644 --- a/homeassistant/components/wyoming/tts.py +++ b/homeassistant/components/wyoming/tts.py @@ -4,7 +4,7 @@ import io import logging import wave -from wyoming.audio import AudioChunk, AudioChunkConverter, AudioStop +from wyoming.audio import AudioChunk, AudioStop from wyoming.client import AsyncTcpClient from wyoming.tts import Synthesize, SynthesizeVoice @@ -16,6 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_SPEAKER, DOMAIN from .data import WyomingService from .error import WyomingError +from .models import DomainDataItem _LOGGER = logging.getLogger(__name__) @@ -26,10 +27,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Wyoming speech-to-text.""" - service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ - WyomingTtsProvider(config_entry, service), + WyomingTtsProvider(config_entry, item.service), ] ) @@ -88,12 +89,16 @@ class WyomingTtsProvider(tts.TextToSpeechEntity): @property def supported_options(self): """Return list of supported options like voice, emotion.""" - return [tts.ATTR_AUDIO_OUTPUT, tts.ATTR_VOICE, ATTR_SPEAKER] + return [ + tts.ATTR_AUDIO_OUTPUT, + tts.ATTR_VOICE, + ATTR_SPEAKER, + ] @property def default_options(self): """Return a dict include default options.""" - return {tts.ATTR_AUDIO_OUTPUT: "wav"} + return {} @callback def async_get_supported_voices(self, language: str) -> list[tts.Voice] | None: @@ -143,27 +148,4 @@ class WyomingTtsProvider(tts.TextToSpeechEntity): except (OSError, WyomingError): return (None, None) - if options[tts.ATTR_AUDIO_OUTPUT] == "wav": - return ("wav", data) - - # Raw output (convert to 16Khz, 16-bit mono) - with io.BytesIO(data) as wav_io: - wav_reader: wave.Wave_read = wave.open(wav_io, "rb") - raw_data = ( - AudioChunkConverter( - rate=16000, - width=2, - channels=1, - ) - .convert( - AudioChunk( - audio=wav_reader.readframes(wav_reader.getnframes()), - rate=wav_reader.getframerate(), - width=wav_reader.getsampwidth(), - channels=wav_reader.getnchannels(), - ) - ) - .audio - ) - - return ("raw", raw_data) + return ("wav", data) diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py index fce8bbf6327..da05e8c9fe1 100644 --- a/homeassistant/components/wyoming/wake_word.py +++ b/homeassistant/components/wyoming/wake_word.py @@ -15,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .data import WyomingService, load_wyoming_info from .error import WyomingError +from .models import DomainDataItem _LOGGER = logging.getLogger(__name__) @@ -25,10 +26,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Wyoming wake-word-detection.""" - service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ - WyomingWakeWordProvider(hass, config_entry, service), + WyomingWakeWordProvider(hass, config_entry, item.service), ] ) diff --git a/homeassistant/components/xbox/strings.json b/homeassistant/components/xbox/strings.json index accd6775941..0d9a12137ce 100644 --- a/homeassistant/components/xbox/strings.json +++ b/homeassistant/components/xbox/strings.json @@ -8,7 +8,11 @@ "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]" + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index a3bb28e7a8b..9be019ed724 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -530,9 +530,6 @@ class XiaomiAirPurifier(XiaomiGenericAirPurifier): This method is a coroutine. """ - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, @@ -623,9 +620,6 @@ class XiaomiAirPurifierMB4(XiaomiGenericAirPurifier): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, @@ -721,9 +715,6 @@ class XiaomiAirFresh(XiaomiGenericAirPurifier): This method is a coroutine. """ - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, @@ -809,9 +800,6 @@ class XiaomiAirFreshA1(XiaomiGenericAirPurifier): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan. This method is a coroutine.""" - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, @@ -958,10 +946,6 @@ class XiaomiFan(XiaomiGenericFan): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return - if preset_mode == ATTR_MODE_NATURE: await self._try_command( "Setting natural fan speed percentage of the miio device failed.", @@ -1034,9 +1018,6 @@ class XiaomiFanP5(XiaomiGenericFan): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, @@ -1093,9 +1074,6 @@ class XiaomiFanMiot(XiaomiGenericFan): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index 9e8b8fed530..307171487bc 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -10,7 +10,7 @@ from aiomusiccast.musiccast_device import MusicCastData, MusicCastDevice from homeassistant.components import ssdp from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, Platform +from homeassistant.const import ATTR_CONNECTIONS, ATTR_VIA_DEVICE, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import ( @@ -176,12 +176,12 @@ class MusicCastDeviceEntity(MusicCastEntity): ) if self._zone_id == DEFAULT_ZONE: - device_info["connections"] = { + device_info[ATTR_CONNECTIONS] = { (CONNECTION_NETWORK_MAC, format_mac(mac)) for mac in self.coordinator.data.mac_addresses.values() } else: - device_info["via_device"] = (DOMAIN, self.coordinator.data.device_id) + device_info[ATTR_VIA_DEVICE] = (DOMAIN, self.coordinator.data.device_id) return device_info diff --git a/homeassistant/components/yamaha_musiccast/config_flow.py b/homeassistant/components/yamaha_musiccast/config_flow.py index 94153a47fdc..b64f5aba6b7 100644 --- a/homeassistant/components/yamaha_musiccast/config_flow.py +++ b/homeassistant/components/yamaha_musiccast/config_flow.py @@ -95,9 +95,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): self.upnp_description = discovery_info.ssdp_location # ssdp_location and hostname have been checked in check_yamaha_ssdp so it is safe to ignore type assignment - self.host = urlparse( - discovery_info.ssdp_location - ).hostname # type: ignore[assignment] + self.host = urlparse(discovery_info.ssdp_location).hostname # type: ignore[assignment] await self.async_set_unique_id(self.serial_number) self._abort_if_unique_id_configured( diff --git a/homeassistant/components/yamaha_musiccast/strings.json b/homeassistant/components/yamaha_musiccast/strings.json index c4f28fc750b..d0ee6c030a6 100644 --- a/homeassistant/components/yamaha_musiccast/strings.json +++ b/homeassistant/components/yamaha_musiccast/strings.json @@ -6,6 +6,9 @@ "description": "Set up MusicCast to integrate with Home Assistant.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Yamaha MusicCast receiver." } }, "confirm": { diff --git a/homeassistant/components/yardian/strings.json b/homeassistant/components/yardian/strings.json index f841f3d3ed1..fcaef65ee3e 100644 --- a/homeassistant/components/yardian/strings.json +++ b/homeassistant/components/yardian/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "host": "Hostname or IP address of your Yardian Smart Sprinkler Controller. You can find it in the Yardian app." } } }, diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 6c44736fa6d..b3bc0c30bf4 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], "quality_scale": "platinum", - "requirements": ["yeelight==0.7.13", "async-upnp-client==0.36.2"], + "requirements": ["yeelight==0.7.14", "async-upnp-client==0.36.2"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/components/yeelight/strings.json b/homeassistant/components/yeelight/strings.json index ab22f42dae3..72baec52c85 100644 --- a/homeassistant/components/yeelight/strings.json +++ b/homeassistant/components/yeelight/strings.json @@ -6,6 +6,9 @@ "description": "If you leave the host empty, discovery will be used to find devices.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Yeelight Wi-Fi bulb." } }, "pick_device": { diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index b1cd8d87a75..212d7ced7d7 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -16,7 +16,10 @@ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" @@ -36,21 +39,11 @@ }, "entity": { "switch": { - "usb_ports": { - "name": "USB ports" - }, - "plug_1": { - "name": "Plug 1" - }, - "plug_2": { - "name": "Plug 2" - }, - "plug_3": { - "name": "Plug 3" - }, - "plug_4": { - "name": "Plug 4" - } + "usb_ports": { "name": "USB ports" }, + "plug_1": { "name": "Plug 1" }, + "plug_2": { "name": "Plug 2" }, + "plug_3": { "name": "Plug 3" }, + "plug_4": { "name": "Plug 4" } }, "sensor": { "power_failure_alarm": { @@ -63,18 +56,11 @@ }, "power_failure_alarm_mute": { "name": "Power failure alarm mute", - "state": { - "muted": "Muted", - "unmuted": "Unmuted" - } + "state": { "muted": "Muted", "unmuted": "Unmuted" } }, "power_failure_alarm_volume": { "name": "Power failure alarm volume", - "state": { - "low": "Low", - "medium": "Medium", - "high": "High" - } + "state": { "low": "Low", "medium": "Medium", "high": "High" } }, "power_failure_alarm_beep": { "name": "Power failure alarm beep", diff --git a/homeassistant/components/youless/strings.json b/homeassistant/components/youless/strings.json index 563e6834ddd..e0eddd7d137 100644 --- a/homeassistant/components/youless/strings.json +++ b/homeassistant/components/youless/strings.json @@ -5,6 +5,9 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your YouLess device." } } }, diff --git a/homeassistant/components/youtube/strings.json b/homeassistant/components/youtube/strings.json index 1b9ecbc1cb3..d664e2f15e7 100644 --- a/homeassistant/components/youtube/strings.json +++ b/homeassistant/components/youtube/strings.json @@ -6,7 +6,11 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "no_subscriptions": "You need to be subscribed to YouTube channels in order to add them.", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", @@ -15,9 +19,7 @@ "step": { "channels": { "description": "Select the channels you want to add.", - "data": { - "channels": "YouTube channels" - } + "data": { "channels": "YouTube channels" } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", @@ -40,17 +42,11 @@ "latest_upload": { "name": "Latest upload", "state_attributes": { - "video_id": { - "name": "Video ID" - }, - "published_at": { - "name": "Published at" - } + "video_id": { "name": "Video ID" }, + "published_at": { "name": "Published at" } } }, - "subscribers": { - "name": "Subscribers" - } + "subscribers": { "name": "Subscribers" } } } } diff --git a/homeassistant/components/zamg/manifest.json b/homeassistant/components/zamg/manifest.json index df17672231e..f83e38002b8 100644 --- a/homeassistant/components/zamg/manifest.json +++ b/homeassistant/components/zamg/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zamg", "iot_class": "cloud_polling", - "requirements": ["zamg==0.3.0"] + "requirements": ["zamg==0.3.3"] } diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 8509d8133e2..5eb77b0c41c 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.119.0"] + "requirements": ["zeroconf==0.127.0"] } diff --git a/homeassistant/components/zeversolar/strings.json b/homeassistant/components/zeversolar/strings.json index 0e2e23f244c..b75bbe781ef 100644 --- a/homeassistant/components/zeversolar/strings.json +++ b/homeassistant/components/zeversolar/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Zeversolar inverter." } } }, diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 222c7f1d4ef..2046070d6a5 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -9,12 +9,12 @@ import re import voluptuous as vol from zhaquirks import setup as setup_quirks from zigpy.config import CONF_DATABASE, CONF_DEVICE, CONF_DEVICE_PATH -from zigpy.exceptions import NetworkSettingsInconsistent +from zigpy.exceptions import NetworkSettingsInconsistent, TransientConnectionError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TYPE, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -29,6 +29,7 @@ from .core.const import ( CONF_CUSTOM_QUIRKS_PATH, CONF_DEVICE_CONFIG, CONF_ENABLE_QUIRKS, + CONF_FLOW_CONTROL, CONF_RADIO_TYPE, CONF_USB_PATH, CONF_ZIGPY, @@ -36,6 +37,8 @@ from .core.const import ( DOMAIN, PLATFORMS, SIGNAL_ADD_ENTITIES, + STARTUP_FAILURE_DELAY_S, + STARTUP_RETRIES, RadioType, ) from .core.device import get_device_automation_triggers @@ -158,42 +161,67 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b _LOGGER.debug("Trigger cache: %s", zha_data.device_trigger_cache) - zha_gateway = ZHAGateway(hass, zha_data.yaml_config, config_entry) + # Retry setup a few times before giving up to deal with missing serial ports in VMs + for attempt in range(STARTUP_RETRIES): + try: + zha_gateway = await ZHAGateway.async_from_config( + hass=hass, + config=zha_data.yaml_config, + config_entry=config_entry, + ) + break + except NetworkSettingsInconsistent as exc: + await warn_on_inconsistent_network_settings( + hass, + config_entry=config_entry, + old_state=exc.old_state, + new_state=exc.new_state, + ) + raise ConfigEntryError( + "Network settings do not match most recent backup" + ) from exc + except TransientConnectionError as exc: + raise ConfigEntryNotReady from exc + except Exception as exc: # pylint: disable=broad-except + _LOGGER.debug( + "Couldn't start coordinator (attempt %s of %s)", + attempt + 1, + STARTUP_RETRIES, + exc_info=exc, + ) - try: - await zha_gateway.async_initialize() - except NetworkSettingsInconsistent as exc: - await warn_on_inconsistent_network_settings( - hass, - config_entry=config_entry, - old_state=exc.old_state, - new_state=exc.new_state, - ) - raise HomeAssistantError( - "Network settings do not match most recent backup" - ) from exc - except Exception: - if RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp: - try: - await warn_on_wrong_silabs_firmware( - hass, config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] - ) - except AlreadyRunningEZSP as exc: - # If connecting fails but we somehow probe EZSP (e.g. stuck in the - # bootloader), reconnect, it should work - raise ConfigEntryNotReady from exc + if attempt < STARTUP_RETRIES - 1: + await asyncio.sleep(STARTUP_FAILURE_DELAY_S) + continue - raise + if RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp: + try: + # Ignore all exceptions during probing, they shouldn't halt setup + await warn_on_wrong_silabs_firmware( + hass, config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] + ) + except AlreadyRunningEZSP as ezsp_exc: + raise ConfigEntryNotReady from ezsp_exc + + raise repairs.async_delete_blocking_issues(hass) + manufacturer = zha_gateway.state.node_info.manufacturer + model = zha_gateway.state.node_info.model + + if manufacturer is None and model is None: + manufacturer = "Unknown" + model = "Unknown" + device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_ZIGBEE, str(zha_gateway.coordinator_ieee))}, - identifiers={(DOMAIN, str(zha_gateway.coordinator_ieee))}, + connections={(dr.CONNECTION_ZIGBEE, str(zha_gateway.state.node_info.ieee))}, + identifiers={(DOMAIN, str(zha_gateway.state.node_info.ieee))}, name="Zigbee Coordinator", - manufacturer="ZHA", - model=zha_gateway.radio_description, + manufacturer=manufacturer, + model=model, + sw_version=zha_gateway.state.node_info.version, ) websocket_api.async_load_api(hass) @@ -267,5 +295,23 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry.version = 3 hass.config_entries.async_update_entry(config_entry, data=data) + if config_entry.version == 3: + data = {**config_entry.data} + + if not data[CONF_DEVICE].get(CONF_BAUDRATE): + data[CONF_DEVICE][CONF_BAUDRATE] = { + "deconz": 38400, + "xbee": 57600, + "ezsp": 57600, + "znp": 115200, + "zigate": 115200, + }[data[CONF_RADIO_TYPE]] + + if not data[CONF_DEVICE].get(CONF_FLOW_CONTROL): + data[CONF_DEVICE][CONF_FLOW_CONTROL] = None + + config_entry.version = 4 + hass.config_entries.async_update_entry(config_entry, data=data) + _LOGGER.info("Migration to version %s successful", config_entry.version) return True diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 1b6bbee5159..60cf917d9f6 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -27,12 +27,13 @@ from homeassistant.util import dt as dt_util from .core.const import ( CONF_BAUDRATE, - CONF_FLOWCONTROL, + CONF_FLOW_CONTROL, CONF_RADIO_TYPE, DOMAIN, RadioType, ) from .radio_manager import ( + DEVICE_SCHEMA, HARDWARE_DISCOVERY_SCHEMA, RECOMMENDED_RADIOS, ProbeResult, @@ -42,7 +43,7 @@ from .radio_manager import ( CONF_MANUAL_PATH = "Enter Manually" SUPPORTED_PORT_SETTINGS = ( CONF_BAUDRATE, - CONF_FLOWCONTROL, + CONF_FLOW_CONTROL, ) DECONZ_DOMAIN = "deconz" @@ -160,7 +161,7 @@ class BaseZhaFlow(FlowHandler): return self.async_create_entry( title=self._title, data={ - CONF_DEVICE: device_settings, + CONF_DEVICE: DEVICE_SCHEMA(device_settings), CONF_RADIO_TYPE: self._radio_mgr.radio_type.name, }, ) @@ -281,7 +282,7 @@ class BaseZhaFlow(FlowHandler): for ( param, value, - ) in self._radio_mgr.radio_type.controller.SCHEMA_DEVICE.schema.items(): + ) in DEVICE_SCHEMA.schema.items(): if param not in SUPPORTED_PORT_SETTINGS: continue @@ -488,7 +489,7 @@ class BaseZhaFlow(FlowHandler): class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow.""" - VERSION = 3 + VERSION = 4 async def _set_unique_id_or_update_path( self, unique_id: str, device_path: str @@ -646,22 +647,17 @@ class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN name = discovery_data["name"] radio_type = self._radio_mgr.parse_radio_type(discovery_data["radio_type"]) - - try: - device_settings = radio_type.controller.SCHEMA_DEVICE( - discovery_data["port"] - ) - except vol.Invalid: - return self.async_abort(reason="invalid_hardware_data") + device_settings = discovery_data["port"] + device_path = device_settings[CONF_DEVICE_PATH] await self._set_unique_id_or_update_path( - unique_id=f"{name}_{radio_type.name}_{device_settings[CONF_DEVICE_PATH]}", - device_path=device_settings[CONF_DEVICE_PATH], + unique_id=f"{name}_{radio_type.name}_{device_path}", + device_path=device_path, ) self._title = name self._radio_mgr.radio_type = radio_type - self._radio_mgr.device_path = device_settings[CONF_DEVICE_PATH] + self._radio_mgr.device_path = device_path self._radio_mgr.device_settings = device_settings self.context["title_placeholders"] = {CONF_NAME: name} diff --git a/homeassistant/components/zha/core/cluster_handlers/closures.py b/homeassistant/components/zha/core/cluster_handlers/closures.py index 980a6f88a75..16c7aef89ad 100644 --- a/homeassistant/components/zha/core/cluster_handlers/closures.py +++ b/homeassistant/components/zha/core/cluster_handlers/closures.py @@ -1,6 +1,9 @@ """Closures cluster handlers module for Zigbee Home Automation.""" -from typing import Any +from __future__ import annotations +from typing import TYPE_CHECKING, Any + +import zigpy.zcl from zigpy.zcl.clusters import closures from homeassistant.core import callback @@ -9,6 +12,9 @@ from .. import registries from ..const import REPORT_CONFIG_IMMEDIATE, SIGNAL_ATTR_UPDATED from . import AttrReportConfig, ClientClusterHandler, ClusterHandler +if TYPE_CHECKING: + from ..endpoint import Endpoint + @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(closures.DoorLock.cluster_id) class DoorLockClusterHandler(ClusterHandler): @@ -139,6 +145,14 @@ class WindowCovering(ClusterHandler): ), ) + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize WindowCovering cluster handler.""" + super().__init__(cluster, endpoint) + + if self.cluster.endpoint.model == "lumi.curtain.agl001": + self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() + self.ZCL_INIT_ATTRS["window_covering_mode"] = True + async def async_update(self): """Retrieve latest state.""" result = await self.get_attribute_value( diff --git a/homeassistant/components/zha/core/cluster_handlers/general.py b/homeassistant/components/zha/core/cluster_handlers/general.py index 6ca4e420d5f..8bc6902b4ff 100644 --- a/homeassistant/components/zha/core/cluster_handlers/general.py +++ b/homeassistant/components/zha/core/cluster_handlers/general.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Coroutine from typing import TYPE_CHECKING, Any +from zhaquirks.quirk_ids import TUYA_PLUG_ONOFF import zigpy.exceptions import zigpy.types as t import zigpy.zcl @@ -347,26 +348,10 @@ class OnOffClusterHandler(ClusterHandler): super().__init__(cluster, endpoint) self._off_listener = None - if self.cluster.endpoint.model not in ( - "TS011F", - "TS0121", - "TS0001", - "TS0002", - "TS0003", - "TS0004", - ): - return - - try: - self.cluster.find_attribute("backlight_mode") - except KeyError: - return - - self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() - self.ZCL_INIT_ATTRS["backlight_mode"] = True - self.ZCL_INIT_ATTRS["power_on_state"] = True - - if self.cluster.endpoint.model == "TS011F": + if endpoint.device.quirk_id == TUYA_PLUG_ONOFF: + self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() + self.ZCL_INIT_ATTRS["backlight_mode"] = True + self.ZCL_INIT_ATTRS["power_on_state"] = True self.ZCL_INIT_ATTRS["child_lock"] = True @classmethod diff --git a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py index f2e5dafa099..99c1e954a0e 100644 --- a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py +++ b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py @@ -5,6 +5,7 @@ import logging from typing import TYPE_CHECKING, Any from zhaquirks.inovelli.types import AllLEDEffectType, SingleLEDEffectType +from zhaquirks.quirk_ids import TUYA_PLUG_MANUFACTURER import zigpy.zcl from homeassistant.core import callback @@ -72,25 +73,7 @@ class TuyaClusterHandler(ClusterHandler): def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: """Initialize TuyaClusterHandler.""" super().__init__(cluster, endpoint) - - if self.cluster.endpoint.manufacturer in ( - "_TZE200_7tdtqgwv", - "_TZE200_amp6tsvy", - "_TZE200_oisqyl4o", - "_TZE200_vhy3iakz", - "_TZ3000_uim07oem", - "_TZE200_wfxuhoea", - "_TZE200_tviaymwx", - "_TZE200_g1ib5ldv", - "_TZE200_wunufsil", - "_TZE200_7deq70b8", - "_TZE200_tz32mtza", - "_TZE200_2hf7x9n3", - "_TZE200_aqnazj70", - "_TZE200_1ozguk6x", - "_TZE200_k6jhsr0q", - "_TZE200_9mahtqtg", - ): + if endpoint.device.quirk_id == TUYA_PLUG_MANUFACTURER: self.ZCL_INIT_ATTRS = { "backlight_mode": True, "power_on_state": True, @@ -241,49 +224,94 @@ class InovelliConfigEntityClusterHandler(ClusterHandler): """Inovelli Configuration Entity cluster handler.""" REPORT_CONFIG = () - ZCL_INIT_ATTRS = { - "dimming_speed_up_remote": True, - "dimming_speed_up_local": True, - "ramp_rate_off_to_on_local": True, - "ramp_rate_off_to_on_remote": True, - "dimming_speed_down_remote": True, - "dimming_speed_down_local": True, - "ramp_rate_on_to_off_local": True, - "ramp_rate_on_to_off_remote": True, - "minimum_level": True, - "maximum_level": True, - "invert_switch": True, - "auto_off_timer": True, - "default_level_local": True, - "default_level_remote": True, - "state_after_power_restored": True, - "load_level_indicator_timeout": True, - "active_power_reports": True, - "periodic_power_and_energy_reports": True, - "active_energy_reports": True, - "power_type": False, - "switch_type": False, - "increased_non_neutral_output": True, - "button_delay": False, - "smart_bulb_mode": False, - "double_tap_up_enabled": True, - "double_tap_down_enabled": True, - "double_tap_up_level": True, - "double_tap_down_level": True, - "led_color_when_on": True, - "led_color_when_off": True, - "led_intensity_when_on": True, - "led_intensity_when_off": True, - "led_scaling_mode": True, - "aux_switch_scenes": True, - "binding_off_to_on_sync_level": True, - "local_protection": False, - "output_mode": False, - "on_off_led_mode": True, - "firmware_progress_led": True, - "relay_click_in_on_off_mode": True, - "disable_clear_notifications_double_tap": True, - } + + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize Inovelli cluster handler.""" + super().__init__(cluster, endpoint) + if self.cluster.endpoint.model == "VZM31-SN": + self.ZCL_INIT_ATTRS = { + "dimming_speed_up_remote": True, + "dimming_speed_up_local": True, + "ramp_rate_off_to_on_local": True, + "ramp_rate_off_to_on_remote": True, + "dimming_speed_down_remote": True, + "dimming_speed_down_local": True, + "ramp_rate_on_to_off_local": True, + "ramp_rate_on_to_off_remote": True, + "minimum_level": True, + "maximum_level": True, + "invert_switch": True, + "auto_off_timer": True, + "default_level_local": True, + "default_level_remote": True, + "state_after_power_restored": True, + "load_level_indicator_timeout": True, + "active_power_reports": True, + "periodic_power_and_energy_reports": True, + "active_energy_reports": True, + "power_type": False, + "switch_type": False, + "increased_non_neutral_output": True, + "button_delay": False, + "smart_bulb_mode": False, + "double_tap_up_enabled": True, + "double_tap_down_enabled": True, + "double_tap_up_level": True, + "double_tap_down_level": True, + "led_color_when_on": True, + "led_color_when_off": True, + "led_intensity_when_on": True, + "led_intensity_when_off": True, + "led_scaling_mode": True, + "aux_switch_scenes": True, + "binding_off_to_on_sync_level": True, + "local_protection": False, + "output_mode": False, + "on_off_led_mode": True, + "firmware_progress_led": True, + "relay_click_in_on_off_mode": True, + "disable_clear_notifications_double_tap": True, + } + elif self.cluster.endpoint.model == "VZM35-SN": + self.ZCL_INIT_ATTRS = { + "dimming_speed_up_remote": True, + "dimming_speed_up_local": True, + "ramp_rate_off_to_on_local": True, + "ramp_rate_off_to_on_remote": True, + "dimming_speed_down_remote": True, + "dimming_speed_down_local": True, + "ramp_rate_on_to_off_local": True, + "ramp_rate_on_to_off_remote": True, + "minimum_level": True, + "maximum_level": True, + "invert_switch": True, + "auto_off_timer": True, + "default_level_local": True, + "default_level_remote": True, + "state_after_power_restored": True, + "load_level_indicator_timeout": True, + "power_type": False, + "switch_type": False, + "non_neutral_aux_med_gear_learn_value": True, + "non_neutral_aux_low_gear_learn_value": True, + "quick_start_time": False, + "button_delay": False, + "smart_fan_mode": False, + "double_tap_up_enabled": True, + "double_tap_down_enabled": True, + "double_tap_up_level": True, + "double_tap_down_level": True, + "led_color_when_on": True, + "led_color_when_off": True, + "led_intensity_when_on": True, + "led_intensity_when_off": True, + "aux_switch_scenes": True, + "local_protection": False, + "output_mode": False, + "on_off_led_mode": True, + "firmware_progress_led": True, + "smart_fan_led_display_levels": True, + } async def issue_all_led_effect( self, diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 9874fddc598..f89ed8d9a52 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -127,6 +127,7 @@ CONF_ALARM_FAILED_TRIES = "alarm_failed_tries" CONF_ALARM_ARM_REQUIRES_CODE = "alarm_arm_requires_code" CONF_BAUDRATE = "baudrate" +CONF_FLOW_CONTROL = "flow_control" CONF_CUSTOM_QUIRKS_PATH = "custom_quirks_path" CONF_DEFAULT_LIGHT_TRANSITION = "default_light_transition" CONF_DEVICE_CONFIG = "device_config" @@ -136,7 +137,6 @@ CONF_ALWAYS_PREFER_XY_COLOR_MODE = "always_prefer_xy_color_mode" CONF_GROUP_MEMBERS_ASSUME_STATE = "group_members_assume_state" CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join" CONF_ENABLE_QUIRKS = "enable_quirks" -CONF_FLOWCONTROL = "flow_control" CONF_RADIO_TYPE = "radio_type" CONF_USB_PATH = "usb_path" CONF_USE_THREAD = "use_thread" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 44acbb172fc..0ce6f47b61e 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -285,7 +285,7 @@ class ZHADevice(LogMixin): if not self.is_coordinator: return False - return self.ieee == self.gateway.coordinator_ieee + return self.ieee == self.gateway.state.node_info.ieee @property def is_end_device(self) -> bool | None: diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index b4c02d33015..5c038a2d7f8 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -11,7 +11,7 @@ import itertools import logging import re import time -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple, Self from zigpy.application import ControllerApplication from zigpy.config import ( @@ -24,15 +24,14 @@ from zigpy.config import ( ) import zigpy.device import zigpy.endpoint -from zigpy.exceptions import NetworkSettingsInconsistent, TransientConnectionError import zigpy.group +from zigpy.state import State from zigpy.types.named import EUI64 from homeassistant import __path__ as HOMEASSISTANT_PATH from homeassistant.components.system_log import LogEntry, _figure_out_source from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -66,8 +65,6 @@ from .const import ( SIGNAL_ADD_ENTITIES, SIGNAL_GROUP_MEMBERSHIP_CHANGE, SIGNAL_REMOVE, - STARTUP_FAILURE_DELAY_S, - STARTUP_RETRIES, UNKNOWN_MANUFACTURER, UNKNOWN_MODEL, ZHA_GW_MSG, @@ -123,10 +120,6 @@ class DevicePairingStatus(Enum): class ZHAGateway: """Gateway that handles events that happen on the ZHA Zigbee network.""" - # -- Set in async_initialize -- - application_controller: ControllerApplication - radio_description: str - def __init__( self, hass: HomeAssistant, config: ConfigType, config_entry: ConfigEntry ) -> None: @@ -135,7 +128,8 @@ class ZHAGateway: self._config = config self._devices: dict[EUI64, ZHADevice] = {} self._groups: dict[int, ZHAGroup] = {} - self.coordinator_zha_device: ZHADevice | None = None + self.application_controller: ControllerApplication = None + self.coordinator_zha_device: ZHADevice = None # type: ignore[assignment] self._device_registry: collections.defaultdict[ EUI64, list[EntityReference] ] = collections.defaultdict(list) @@ -147,13 +141,11 @@ class ZHAGateway: self._log_relay_handler = LogRelayHandler(hass, self) self.config_entry = config_entry self._unsubs: list[Callable[[], None]] = [] + self.shutting_down = False def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: """Get an uninitialized instance of a zigpy `ControllerApplication`.""" - radio_type = self.config_entry.data[CONF_RADIO_TYPE] - - app_controller_cls = RadioType[radio_type].controller - self.radio_description = RadioType[radio_type].description + radio_type = RadioType[self.config_entry.data[CONF_RADIO_TYPE]] app_config = self._config.get(CONF_ZIGPY, {}) database = self._config.get( @@ -170,7 +162,7 @@ class ZHAGateway: # event loop, when a connection to a TCP coordinator fails in a specific way if ( CONF_USE_THREAD not in app_config - and RadioType[radio_type] is RadioType.ezsp + and radio_type is RadioType.ezsp and app_config[CONF_DEVICE][CONF_DEVICE_PATH].startswith("socket://") ): app_config[CONF_USE_THREAD] = False @@ -189,48 +181,40 @@ class ZHAGateway: ): app_config.setdefault(CONF_NWK, {})[CONF_NWK_CHANNEL] = 15 - return app_controller_cls, app_controller_cls.SCHEMA(app_config) + return radio_type.controller, radio_type.controller.SCHEMA(app_config) + + @classmethod + async def async_from_config( + cls, hass: HomeAssistant, config: ConfigType, config_entry: ConfigEntry + ) -> Self: + """Create an instance of a gateway from config objects.""" + instance = cls(hass, config, config_entry) + await instance.async_initialize() + return instance async def async_initialize(self) -> None: """Initialize controller and connect radio.""" discovery.PROBE.initialize(self.hass) discovery.GROUP_PROBE.initialize(self.hass) + self.shutting_down = False + app_controller_cls, app_config = self.get_application_controller_data() - self.application_controller = await app_controller_cls.new( + app = await app_controller_cls.new( config=app_config, auto_form=False, start_radio=False, ) try: - for attempt in range(STARTUP_RETRIES): - try: - await self.application_controller.startup(auto_form=True) - except TransientConnectionError as exc: - raise ConfigEntryNotReady from exc - except NetworkSettingsInconsistent: - raise - except Exception as exc: # pylint: disable=broad-except - _LOGGER.debug( - "Couldn't start %s coordinator (attempt %s of %s)", - self.radio_description, - attempt + 1, - STARTUP_RETRIES, - exc_info=exc, - ) - - if attempt == STARTUP_RETRIES - 1: - raise exc - - await asyncio.sleep(STARTUP_FAILURE_DELAY_S) - else: - break + await app.startup(auto_form=True) except Exception: # Explicitly shut down the controller application on failure - await self.application_controller.shutdown() + await app.shutdown() raise + self.application_controller = app + zha_data = get_zha_data(self.hass) zha_data.gateway = self @@ -244,6 +228,17 @@ class ZHAGateway: self.application_controller.add_listener(self) self.application_controller.groups.add_listener(self) + def connection_lost(self, exc: Exception) -> None: + """Handle connection lost event.""" + if self.shutting_down: + return + + _LOGGER.debug("Connection to the radio was lost: %r", exc) + + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.config_entry.entry_id) + ) + def _find_coordinator_device(self) -> zigpy.device.Device: zigpy_coordinator = self.application_controller.get_device(nwk=0x0000) @@ -258,6 +253,7 @@ class ZHAGateway: @callback def async_load_devices(self) -> None: """Restore ZHA devices from zigpy application state.""" + for zigpy_device in self.application_controller.devices.values(): zha_device = self._async_get_or_create_device(zigpy_device, restored=True) delta_msg = "not known" @@ -280,6 +276,7 @@ class ZHAGateway: @callback def async_load_groups(self) -> None: """Initialize ZHA groups.""" + for group_id in self.application_controller.groups: group = self.application_controller.groups[group_id] zha_group = self._async_get_or_create_group(group) @@ -521,9 +518,9 @@ class ZHAGateway: entity_registry.async_remove(entry.entity_id) @property - def coordinator_ieee(self) -> EUI64: - """Return the active coordinator's IEEE address.""" - return self.application_controller.state.node_info.ieee + def state(self) -> State: + """Return the active coordinator's network state.""" + return self.application_controller.state @property def devices(self) -> dict[EUI64, ZHADevice]: @@ -711,6 +708,7 @@ class ZHAGateway: group_id: int | None = None, ) -> ZHAGroup | None: """Create a new Zigpy Zigbee group.""" + # we start with two to fill any gaps from a user removing existing groups if group_id is None: @@ -758,19 +756,13 @@ class ZHAGateway: async def shutdown(self) -> None: """Stop ZHA Controller Application.""" _LOGGER.debug("Shutting down ZHA ControllerApplication") + self.shutting_down = True + for unsubscribe in self._unsubs: unsubscribe() for device in self.devices.values(): device.async_cleanup_handles() - # shutdown is called when the config entry unloads are processed - # there are cases where unloads are processed because of a failure of - # some sort and the application controller may not have been - # created yet - if ( - hasattr(self, "application_controller") - and self.application_controller is not None - ): - await self.application_controller.shutdown() + await self.application_controller.shutdown() def handle_message( self, diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 05e1da7c570..b92d077907f 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -92,7 +92,7 @@ class BaseZhaEntity(LogMixin, entity.Entity): manufacturer=zha_device_info[ATTR_MANUFACTURER], model=zha_device_info[ATTR_MODEL], name=zha_device_info[ATTR_NAME], - via_device=(DOMAIN, zha_gateway.coordinator_ieee), + via_device=(DOMAIN, zha_gateway.state.node_info.ieee), ) @callback diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 05bf3469c7b..c6b9a104885 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -13,7 +13,6 @@ from homeassistant.components.fan import ( ATTR_PRESET_MODE, FanEntity, FanEntityFeature, - NotValidPresetModeError, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNAVAILABLE, Platform @@ -131,11 +130,6 @@ class BaseFan(FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode for the fan.""" - if preset_mode not in self.preset_modes: - raise NotValidPresetModeError( - f"The preset_mode {preset_mode} is not a valid preset_mode:" - f" {self.preset_modes}" - ) await self._async_set_fan_mode(self.preset_name_to_mode[preset_mode]) @abstractmethod diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 6a01d550466..d545a331a6d 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -1072,7 +1072,7 @@ class HueLight(Light): @STRICT_MATCH( cluster_handler_names=CLUSTER_HANDLER_ON_OFF, aux_cluster_handlers={CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_LEVEL}, - manufacturers={"Jasco", "Quotra-Vision", "eWeLight", "eWeLink"}, + manufacturers={"Jasco", "Jasco Products", "Quotra-Vision", "eWeLight", "eWeLink"}, ) class ForceOnLight(Light): """Representation of a light which does not respect on/off for move_to_level_with_on_off commands.""" diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 6efee0e96ac..4c8a58a12cf 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -2,7 +2,7 @@ "domain": "zha", "name": "Zigbee Home Automation", "after_dependencies": ["onboarding", "usb"], - "codeowners": ["@dmulcahey", "@adminiuga", "@puddly"], + "codeowners": ["@dmulcahey", "@adminiuga", "@puddly", "@TheJulianJES"], "config_flow": true, "dependencies": ["file_upload"], "documentation": "https://www.home-assistant.io/integrations/zha", @@ -21,16 +21,16 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.36.8", + "bellows==0.37.1", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.106", - "zigpy-deconz==0.21.1", - "zigpy==0.59.0", - "zigpy-xbee==0.19.0", - "zigpy-zigate==0.11.0", - "zigpy-znp==0.11.6", - "universal-silabs-flasher==0.0.14", + "zha-quirks==0.0.107", + "zigpy-deconz==0.22.0", + "zigpy==0.60.0", + "zigpy-xbee==0.20.0", + "zigpy-zigate==0.12.0", + "zigpy-znp==0.12.0", + "universal-silabs-flasher==0.0.15", "pyserial-asyncio-fast==0.11" ], "usb": [ @@ -76,6 +76,12 @@ "description": "*conbee*", "known_devices": ["Conbee II"] }, + { + "vid": "0403", + "pid": "6015", + "description": "*conbee*", + "known_devices": ["Conbee III"] + }, { "vid": "10C4", "pid": "8A2A", diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index ae2f9e0b758..53d79d2d35f 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -629,7 +629,7 @@ class InovelliRemoteDimmingUpSpeed(ZHANumberConfigurationEntity): class InovelliButtonDelay(ZHANumberConfigurationEntity): """Inovelli button delay configuration entity.""" - _unique_id_suffix = "dimming_speed_up_local" + _unique_id_suffix = "button_delay" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 @@ -778,6 +778,22 @@ class InovelliAutoShutoffTimer(ZHANumberConfigurationEntity): _attr_translation_key: str = "auto_off_timer" +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, models={"VZM35-SN"} +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class InovelliQuickStartTime(ZHANumberConfigurationEntity): + """Inovelli fan quick start time configuration entity.""" + + _unique_id_suffix = "quick_start_time" + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[3] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 10 + _attribute_name = "quick_start_time" + _attr_translation_key: str = "quick_start_time" + + @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliLoadLevelIndicatorTimeout(ZHANumberConfigurationEntity): diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index d20cf752a91..d3ca03de8d8 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -19,6 +19,7 @@ from zigpy.config import ( CONF_DEVICE, CONF_DEVICE_PATH, CONF_NWK_BACKUP_ENABLED, + SCHEMA_DEVICE, ) from zigpy.exceptions import NetworkNotFormed @@ -58,10 +59,21 @@ RETRY_DELAY_S = 1.0 BACKUP_RETRIES = 5 MIGRATION_RETRIES = 100 + +DEVICE_SCHEMA = vol.Schema( + { + vol.Required("path"): str, + vol.Optional("baudrate", default=115200): int, + vol.Optional("flow_control", default=None): vol.In( + ["hardware", "software", None] + ), + } +) + HARDWARE_DISCOVERY_SCHEMA = vol.Schema( { vol.Required("name"): str, - vol.Required("port"): dict, + vol.Required("port"): DEVICE_SCHEMA, vol.Required("radio_type"): str, } ) @@ -204,9 +216,7 @@ class ZhaRadioManager: for radio in AUTOPROBE_RADIOS: _LOGGER.debug("Attempting to probe radio type %s", radio) - dev_config = radio.controller.SCHEMA_DEVICE( - {CONF_DEVICE_PATH: self.device_path} - ) + dev_config = SCHEMA_DEVICE({CONF_DEVICE_PATH: self.device_path}) probe_result = await radio.controller.probe(dev_config) if not probe_result: @@ -357,7 +367,7 @@ class ZhaMultiPANMigrationHelper: migration_data["new_discovery_info"]["radio_type"] ) - new_device_settings = new_radio_type.controller.SCHEMA_DEVICE( + new_device_settings = SCHEMA_DEVICE( migration_data["new_discovery_info"]["port"] ) diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 46089dd5a28..2ff8b7d36b9 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -6,6 +6,7 @@ import functools import logging from typing import TYPE_CHECKING, Any, Self +from zhaquirks.quirk_ids import TUYA_PLUG_MANUFACTURER, TUYA_PLUG_ONOFF from zigpy import types from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.clusters.security import IasWd @@ -246,29 +247,10 @@ class TuyaPowerOnState(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - cluster_handler_names=CLUSTER_HANDLER_ON_OFF, - models={"TS011F", "TS0121", "TS0001", "TS0002", "TS0003", "TS0004"}, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, quirk_ids=TUYA_PLUG_ONOFF ) @CONFIG_DIAGNOSTIC_MATCH( - cluster_handler_names="tuya_manufacturer", - manufacturers={ - "_TZE200_7tdtqgwv", - "_TZE200_amp6tsvy", - "_TZE200_oisqyl4o", - "_TZE200_vhy3iakz", - "_TZ3000_uim07oem", - "_TZE200_wfxuhoea", - "_TZE200_tviaymwx", - "_TZE200_g1ib5ldv", - "_TZE200_wunufsil", - "_TZE200_7deq70b8", - "_TZE200_tz32mtza", - "_TZE200_2hf7x9n3", - "_TZE200_aqnazj70", - "_TZE200_1ozguk6x", - "_TZE200_k6jhsr0q", - "_TZE200_9mahtqtg", - }, + cluster_handler_names="tuya_manufacturer", quirk_ids=TUYA_PLUG_MANUFACTURER ) class TuyaPowerOnStateSelectEntity(ZCLEnumSelectEntity): """Representation of a ZHA power on state select entity.""" @@ -288,8 +270,7 @@ class TuyaBacklightMode(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - cluster_handler_names=CLUSTER_HANDLER_ON_OFF, - models={"TS011F", "TS0121", "TS0001", "TS0002", "TS0003", "TS0004"}, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, quirk_ids=TUYA_PLUG_ONOFF ) class TuyaBacklightModeSelectEntity(ZCLEnumSelectEntity): """Representation of a ZHA backlight mode select entity.""" @@ -310,25 +291,7 @@ class MoesBacklightMode(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - cluster_handler_names="tuya_manufacturer", - manufacturers={ - "_TZE200_7tdtqgwv", - "_TZE200_amp6tsvy", - "_TZE200_oisqyl4o", - "_TZE200_vhy3iakz", - "_TZ3000_uim07oem", - "_TZE200_wfxuhoea", - "_TZE200_tviaymwx", - "_TZE200_g1ib5ldv", - "_TZE200_wunufsil", - "_TZE200_7deq70b8", - "_TZE200_tz32mtza", - "_TZE200_2hf7x9n3", - "_TZE200_aqnazj70", - "_TZE200_1ozguk6x", - "_TZE200_k6jhsr0q", - "_TZE200_9mahtqtg", - }, + cluster_handler_names="tuya_manufacturer", quirk_ids=TUYA_PLUG_MANUFACTURER ) class MoesBacklightModeSelectEntity(ZCLEnumSelectEntity): """Moes devices have a different backlight mode select options.""" @@ -484,7 +447,7 @@ class InovelliOutputModeEntity(ZCLEnumSelectEntity): class InovelliSwitchType(types.enum8): - """Inovelli output mode.""" + """Inovelli switch mode.""" Single_Pole = 0x00 Three_Way_Dumb = 0x01 @@ -493,7 +456,7 @@ class InovelliSwitchType(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - cluster_handler_names=CLUSTER_HANDLER_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, models={"VZM31-SN"} ) class InovelliSwitchTypeEntity(ZCLEnumSelectEntity): """Inovelli switch type control.""" @@ -504,6 +467,25 @@ class InovelliSwitchTypeEntity(ZCLEnumSelectEntity): _attr_translation_key: str = "switch_type" +class InovelliFanSwitchType(types.enum1): + """Inovelli fan switch mode.""" + + Load_Only = 0x00 + Three_Way_AUX = 0x01 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, models={"VZM35-SN"} +) +class InovelliFanSwitchTypeEntity(ZCLEnumSelectEntity): + """Inovelli fan switch type control.""" + + _unique_id_suffix = "switch_type" + _attribute_name = "switch_type" + _enum = InovelliFanSwitchType + _attr_translation_key: str = "switch_type" + + class InovelliLedScalingMode(types.enum1): """Inovelli led mode.""" @@ -523,6 +505,34 @@ class InovelliLedScalingModeEntity(ZCLEnumSelectEntity): _attr_translation_key: str = "led_scaling_mode" +class InovelliFanLedScalingMode(types.enum8): + """Inovelli fan led mode.""" + + VZM31SN = 0x00 + Grade_1 = 0x01 + Grade_2 = 0x02 + Grade_3 = 0x03 + Grade_4 = 0x04 + Grade_5 = 0x05 + Grade_6 = 0x06 + Grade_7 = 0x07 + Grade_8 = 0x08 + Grade_9 = 0x09 + Adaptive = 0x0A + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, models={"VZM35-SN"} +) +class InovelliFanLedScalingModeEntity(ZCLEnumSelectEntity): + """Inovelli fan switch led mode control.""" + + _unique_id_suffix = "smart_fan_led_display_levels" + _attribute_name = "smart_fan_led_display_levels" + _enum = InovelliFanLedScalingMode + _attr_translation_key: str = "smart_fan_led_display_levels" + + class InovelliNonNeutralOutput(types.enum1): """Inovelli non neutral output selection.""" diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 22c2810ad23..18bb3ae4f82 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -721,6 +721,9 @@ }, "away_preset_temperature": { "name": "Away preset temperature" + }, + "quick_start_time": { + "name": "Quick start time" } }, "select": { @@ -766,6 +769,9 @@ "led_scaling_mode": { "name": "Led scaling mode" }, + "smart_fan_led_display_levels": { + "name": "Smart fan led display levels" + }, "increased_non_neutral_output": { "name": "Non neutral output" }, @@ -878,6 +884,9 @@ "smart_bulb_mode": { "name": "Smart bulb mode" }, + "smart_fan_mode": { + "name": "Smart fan mode" + }, "double_tap_up_enabled": { "name": "Double tap up enabled" }, diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index e49bc44b822..71c6e9d90ad 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -5,6 +5,7 @@ import functools import logging from typing import TYPE_CHECKING, Any, Self +from zhaquirks.quirk_ids import TUYA_PLUG_ONOFF from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.foundation import Status @@ -363,6 +364,17 @@ class InovelliSmartBulbMode(ZHASwitchConfigurationEntity): _attr_translation_key = "smart_bulb_mode" +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, models={"VZM35-SN"} +) +class InovelliSmartFanMode(ZHASwitchConfigurationEntity): + """Inovelli smart fan mode control.""" + + _unique_id_suffix = "smart_fan_mode" + _attribute_name = "smart_fan_mode" + _attr_translation_key = "smart_fan_mode" + + @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) @@ -488,8 +500,7 @@ class AqaraPetFeederChildLock(ZHASwitchConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( - cluster_handler_names=CLUSTER_HANDLER_ON_OFF, - models={"TS011F"}, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, quirk_ids=TUYA_PLUG_ONOFF ) class TuyaChildLockSwitch(ZHASwitchConfigurationEntity): """Representation of a child lock configuration entity.""" diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index a917aa44889..9e50b55830c 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -393,7 +393,7 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_subscribe_node_status) websocket_api.async_register_command(hass, websocket_node_status) websocket_api.async_register_command(hass, websocket_node_metadata) - websocket_api.async_register_command(hass, websocket_node_comments) + 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_grant_security_classes) websocket_api.async_register_command(hass, websocket_validate_dsk_and_enter_pin) @@ -616,22 +616,25 @@ async def websocket_node_metadata( @websocket_api.websocket_command( { - vol.Required(TYPE): "zwave_js/node_comments", + vol.Required(TYPE): "zwave_js/node_alerts", vol.Required(DEVICE_ID): str, } ) @websocket_api.async_response @async_get_node -async def websocket_node_comments( +async def websocket_node_alerts( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], node: Node, ) -> None: - """Get the comments of a Z-Wave JS node.""" + """Get the alerts for a Z-Wave JS node.""" connection.send_result( msg[ID], - {"comments": node.device_config.metadata.comments}, + { + "comments": node.device_config.metadata.comments, + "is_embedded": node.device_config.is_embedded, + }, ) diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index ef5cdd1b1d2..acd6780d39f 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -276,9 +276,7 @@ async def async_setup_entry( if state_key == "0": continue - notification_description: NotificationZWaveJSEntityDescription | None = ( - None - ) + notification_description: NotificationZWaveJSEntityDescription | None = None for description in NOTIFICATION_SENSOR_MAPPINGS: if ( diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 34c6fa3363e..656620d01dd 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -72,6 +72,8 @@ ATTR_STATUS = "status" ATTR_ACKNOWLEDGED_FRAMES = "acknowledged_frames" ATTR_EVENT_TYPE_LABEL = "event_type_label" ATTR_DATA_TYPE_LABEL = "data_type_label" +ATTR_NOTIFICATION_TYPE = "notification_type" +ATTR_NOTIFICATION_EVENT = "notification_event" ATTR_NODE = "node" ATTR_ZWAVE_VALUE = "zwave_value" @@ -92,10 +94,12 @@ SERVICE_CLEAR_LOCK_USERCODE = "clear_lock_usercode" SERVICE_INVOKE_CC_API = "invoke_cc_api" SERVICE_MULTICAST_SET_VALUE = "multicast_set_value" SERVICE_PING = "ping" +SERVICE_REFRESH_NOTIFICATIONS = "refresh_notifications" SERVICE_REFRESH_VALUE = "refresh_value" SERVICE_RESET_METER = "reset_meter" SERVICE_SET_CONFIG_PARAMETER = "set_config_parameter" SERVICE_SET_LOCK_USERCODE = "set_lock_usercode" +SERVICE_SET_LOCK_CONFIGURATION = "set_lock_configuration" SERVICE_SET_VALUE = "set_value" ATTR_NODES = "nodes" @@ -103,6 +107,8 @@ ATTR_NODES = "nodes" ATTR_CONFIG_PARAMETER = "parameter" ATTR_CONFIG_PARAMETER_BITMASK = "bitmask" ATTR_CONFIG_VALUE = "value" +ATTR_VALUE_SIZE = "value_size" +ATTR_VALUE_FORMAT = "value_format" # refresh value ATTR_REFRESH_ALL_VALUES = "refresh_all_values" # multicast @@ -113,6 +119,13 @@ ATTR_METER_TYPE_NAME = "meter_type_name" # invoke CC API ATTR_METHOD_NAME = "method_name" ATTR_PARAMETERS = "parameters" +# lock set configuration +ATTR_AUTO_RELOCK_TIME = "auto_relock_time" +ATTR_BLOCK_TO_BLOCK = "block_to_block" +ATTR_HOLD_AND_RELEASE_TIME = "hold_and_release_time" +ATTR_LOCK_TIMEOUT = "lock_timeout" +ATTR_OPERATION_TYPE = "operation_type" +ATTR_TWIST_ASSIST = "twist_assist" ADDON_SLUG = "core_zwave_js" diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 39d8c0e8855..dfe2294e710 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -530,6 +530,68 @@ DISCOVERY_SCHEMAS = [ primary_value=SWITCH_BINARY_CURRENT_VALUE_SCHEMA, assumed_state=True, ), + # Heatit Z-TRM6 + ZWaveDiscoverySchema( + platform=Platform.CLIMATE, + hint="dynamic_current_temp", + manufacturer_id={0x019B}, + product_id={0x3001}, + product_type={0x0030}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.THERMOSTAT_MODE}, + property={THERMOSTAT_MODE_PROPERTY}, + type={ValueType.NUMBER}, + ), + data_template=DynamicCurrentTempClimateDataTemplate( + lookup_table={ + # Floor sensor + "Floor": ZwaveValueID( + property_=THERMOSTAT_CURRENT_TEMP_PROPERTY, + command_class=CommandClass.SENSOR_MULTILEVEL, + endpoint=4, + ), + # Internal sensor + "Internal": ZwaveValueID( + property_=THERMOSTAT_CURRENT_TEMP_PROPERTY, + command_class=CommandClass.SENSOR_MULTILEVEL, + endpoint=2, + ), + # Internal with limit by floor sensor + "Internal with floor limit": ZwaveValueID( + property_=THERMOSTAT_CURRENT_TEMP_PROPERTY, + command_class=CommandClass.SENSOR_MULTILEVEL, + endpoint=2, + ), + # External sensor (connected to device) + "External": ZwaveValueID( + property_=THERMOSTAT_CURRENT_TEMP_PROPERTY, + command_class=CommandClass.SENSOR_MULTILEVEL, + endpoint=3, + ), + # External sensor (connected to device) with limit by floor sensor (2x sensors) + "External with floor limit": ZwaveValueID( + property_=THERMOSTAT_CURRENT_TEMP_PROPERTY, + command_class=CommandClass.SENSOR_MULTILEVEL, + endpoint=3, + ), + # PWER - Power regulator mode (no sensor used). + # This mode is not supported by the climate entity. + # Heating is set by adjusting parameter 25. + # P25: Set % of time the relay should be active when using PWER mode. + # (30-minute duty cycle) + # Use the air temperature as current temperature in the climate entity + # as we have nothing else. + "Power regulator": ZwaveValueID( + property_=THERMOSTAT_CURRENT_TEMP_PROPERTY, + command_class=CommandClass.SENSOR_MULTILEVEL, + endpoint=2, + ), + }, + dependent_value=ZwaveValueID( + property_=2, command_class=CommandClass.CONFIGURATION, endpoint=0 + ), + ), + ), # Heatit Z-TRM3 ZWaveDiscoverySchema( platform=Platform.CLIMATE, @@ -664,7 +726,14 @@ DISCOVERY_SCHEMAS = [ # locks # Door Lock CC ZWaveDiscoverySchema( - platform=Platform.LOCK, primary_value=DOOR_LOCK_CURRENT_MODE_SCHEMA + platform=Platform.LOCK, + primary_value=DOOR_LOCK_CURRENT_MODE_SCHEMA, + allow_multi=True, + ), + ZWaveDiscoverySchema( + platform=Platform.SELECT, + primary_value=DOOR_LOCK_CURRENT_MODE_SCHEMA, + hint="door_lock", ), # Only discover the Lock CC if the Door Lock CC isn't also present on the node ZWaveDiscoverySchema( diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index d0630649765..d4247b65c8b 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -18,7 +18,6 @@ from homeassistant.components.fan import ( DOMAIN as FAN_DOMAIN, FanEntity, FanEntityFeature, - NotValidPresetModeError, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -181,11 +180,6 @@ class ValueMappingZwaveFan(ZwaveFan): await self._async_set_value(self._target_value, zwave_value) return - raise NotValidPresetModeError( - f"The preset_mode {preset_mode} is not a valid preset_mode:" - f" {self.preset_modes}" - ) - @property def available(self) -> bool: """Return whether the entity is available.""" diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index 5457916a1e1..59faf7fbbb6 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -11,10 +11,12 @@ from zwave_js_server.const.command_class.lock import ( ATTR_USERCODE, LOCK_CMD_CLASS_TO_LOCKED_STATE_MAP, LOCK_CMD_CLASS_TO_PROPERTY_MAP, + DoorLockCCConfigurationSetOptions, DoorLockMode, + OperationType, ) from zwave_js_server.exceptions import BaseZwaveJSServerError -from zwave_js_server.util.lock import clear_usercode, set_usercode +from zwave_js_server.util.lock import clear_usercode, set_configuration, set_usercode from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockEntity from homeassistant.config_entries import ConfigEntry @@ -26,10 +28,17 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( + ATTR_AUTO_RELOCK_TIME, + ATTR_BLOCK_TO_BLOCK, + ATTR_HOLD_AND_RELEASE_TIME, + ATTR_LOCK_TIMEOUT, + ATTR_OPERATION_TYPE, + ATTR_TWIST_ASSIST, DATA_CLIENT, DOMAIN, LOGGER, SERVICE_CLEAR_LOCK_USERCODE, + SERVICE_SET_LOCK_CONFIGURATION, SERVICE_SET_LOCK_USERCODE, ) from .discovery import ZwaveDiscoveryInfo @@ -47,6 +56,7 @@ STATE_TO_ZWAVE_MAP: dict[int, dict[str, int | bool]] = { STATE_LOCKED: True, }, } +UNIT16_SCHEMA = vol.All(vol.Coerce(int), vol.Range(min=0, max=65535)) async def async_setup_entry( @@ -92,6 +102,24 @@ async def async_setup_entry( "async_clear_lock_usercode", ) + platform.async_register_entity_service( + SERVICE_SET_LOCK_CONFIGURATION, + { + vol.Required(ATTR_OPERATION_TYPE): vol.All( + cv.string, + vol.Upper, + vol.In(["TIMED", "CONSTANT"]), + lambda x: OperationType[x], + ), + vol.Optional(ATTR_LOCK_TIMEOUT): UNIT16_SCHEMA, + vol.Optional(ATTR_AUTO_RELOCK_TIME): UNIT16_SCHEMA, + vol.Optional(ATTR_HOLD_AND_RELEASE_TIME): UNIT16_SCHEMA, + vol.Optional(ATTR_TWIST_ASSIST): vol.Coerce(bool), + vol.Optional(ATTR_BLOCK_TO_BLOCK): vol.Coerce(bool), + }, + "async_set_lock_configuration", + ) + class ZWaveLock(ZWaveBaseEntity, LockEntity): """Representation of a Z-Wave lock.""" @@ -138,9 +166,10 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): await set_usercode(self.info.node, code_slot, usercode) except BaseZwaveJSServerError as err: raise HomeAssistantError( - f"Unable to set lock usercode on code_slot {code_slot}: {err}" + f"Unable to set lock usercode on lock {self.entity_id} code_slot " + f"{code_slot}: {err}" ) from err - LOGGER.debug("User code at slot %s set", code_slot) + LOGGER.debug("User code at slot %s on lock %s set", code_slot, self.entity_id) async def async_clear_lock_usercode(self, code_slot: int) -> None: """Clear the usercode at index X on the lock.""" @@ -148,6 +177,41 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): await clear_usercode(self.info.node, code_slot) except BaseZwaveJSServerError as err: raise HomeAssistantError( - f"Unable to clear lock usercode on code_slot {code_slot}: {err}" + f"Unable to clear lock usercode on lock {self.entity_id} code_slot " + f"{code_slot}: {err}" ) from err - LOGGER.debug("User code at slot %s cleared", code_slot) + LOGGER.debug( + "User code at slot %s on lock %s cleared", code_slot, self.entity_id + ) + + async def async_set_lock_configuration( + self, + operation_type: OperationType, + lock_timeout: int | None = None, + auto_relock_time: int | None = None, + hold_and_release_time: int | None = None, + twist_assist: bool | None = None, + block_to_block: bool | None = None, + ) -> None: + """Set the lock configuration.""" + params: dict[str, Any] = {"operation_type": operation_type} + for attr, val in ( + ("lock_timeout_configuration", lock_timeout), + ("auto_relock_time", auto_relock_time), + ("hold_and_release_time", hold_and_release_time), + ("twist_assist", twist_assist), + ("block_to_block", block_to_block), + ): + if val is not None: + params[attr] = val + configuration = DoorLockCCConfigurationSetOptions(**params) + result = await set_configuration( + self.info.node.endpoints[self.info.primary_value.endpoint or 0], + configuration, + ) + if result is None: + return + msg = f"Result status is {result.status}" + if result.remaining_duration is not None: + msg += f" and remaining duration is {str(result.remaining_duration)}" + LOGGER.info("%s after setting lock configuration for %s", msg, self.entity_id) diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index 3956004336a..e838949d3e1 100644 --- a/homeassistant/components/zwave_js/select.py +++ b/homeassistant/components/zwave_js/select.py @@ -5,7 +5,8 @@ from typing import cast from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import TARGET_VALUE_PROPERTY, CommandClass -from zwave_js_server.const.command_class.sound_switch import ToneID +from zwave_js_server.const.command_class.lock import TARGET_MODE_PROPERTY +from zwave_js_server.const.command_class.sound_switch import TONE_ID_PROPERTY, ToneID from zwave_js_server.model.driver import Driver from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity @@ -46,6 +47,8 @@ async def async_setup_entry( entities.append( ZWaveConfigParameterSelectEntity(config_entry, driver, info) ) + elif info.platform_hint == "door_lock": + entities.append(ZWaveDoorLockSelectEntity(config_entry, driver, info)) else: entities.append(ZwaveSelectEntity(config_entry, driver, info)) async_add_entities(entities) @@ -95,6 +98,27 @@ class ZwaveSelectEntity(ZWaveBaseEntity, SelectEntity): await self._async_set_value(self.info.primary_value, int(key)) +class ZWaveDoorLockSelectEntity(ZwaveSelectEntity): + """Representation of a Z-Wave door lock CC mode select entity.""" + + def __init__( + self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZWaveDoorLockSelectEntity entity.""" + super().__init__(config_entry, driver, info) + self._target_value = self.get_zwave_value(TARGET_MODE_PROPERTY) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + assert self._target_value is not None + key = next( + key + for key, val in self.info.primary_value.metadata.states.items() + if val == option + ) + await self._async_set_value(self._target_value, int(key)) + + class ZWaveConfigParameterSelectEntity(ZwaveSelectEntity): """Representation of a Z-Wave config parameter select.""" @@ -125,7 +149,7 @@ class ZwaveDefaultToneSelectEntity(ZWaveBaseEntity, SelectEntity): """Initialize a ZwaveDefaultToneSelectEntity entity.""" super().__init__(config_entry, driver, info) self._tones_value = self.get_zwave_value( - "toneId", command_class=CommandClass.SOUND_SWITCH + TONE_ID_PROPERTY, command_class=CommandClass.SOUND_SWITCH ) # Entity class attributes diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 44ef3a2269c..12c1ed242af 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -4,15 +4,21 @@ from __future__ import annotations import asyncio from collections.abc import Generator, Sequence import logging -from typing import Any +import math +from typing import Any, TypeVar import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import SET_VALUE_SUCCESS, CommandClass, CommandStatus +from zwave_js_server.const.command_class.notification import NotificationType from zwave_js_server.exceptions import FailedZWaveCommand, SetValueFailed from zwave_js_server.model.endpoint import Endpoint from zwave_js_server.model.node import Node as ZwaveNode -from zwave_js_server.model.value import ValueDataType, get_value_id_str +from zwave_js_server.model.value import ( + ConfigurationValueFormat, + ValueDataType, + get_value_id_str, +) from zwave_js_server.util.multicast import async_multicast_set_value from zwave_js_server.util.node import ( async_bulk_set_partial_config_parameters, @@ -39,6 +45,8 @@ from .helpers import ( _LOGGER = logging.getLogger(__name__) +T = TypeVar("T", ZwaveNode, Endpoint) + def parameter_name_does_not_need_bitmask( val: dict[str, int | str | list[str]] @@ -55,6 +63,13 @@ def parameter_name_does_not_need_bitmask( return val +def check_base_2(val: int) -> int: + """Check if value is a power of 2.""" + if not math.log2(val).is_integer(): + raise vol.Invalid("Value must be a power of 2.") + return val + + def broadcast_command(val: dict[str, Any]) -> dict[str, Any]: """Validate that the service call is for a broadcast command.""" if val.get(const.ATTR_BROADCAST): @@ -66,8 +81,8 @@ def broadcast_command(val: dict[str, Any]) -> dict[str, Any]: def get_valid_responses_from_results( - zwave_objects: Sequence[ZwaveNode | Endpoint], results: Sequence[Any] -) -> Generator[tuple[ZwaveNode | Endpoint, Any], None, None]: + zwave_objects: Sequence[T], results: Sequence[Any] +) -> Generator[tuple[T, Any], None, None]: """Return valid responses from a list of results.""" for zwave_object, result in zip(zwave_objects, results): if not isinstance(result, Exception): @@ -75,10 +90,10 @@ def get_valid_responses_from_results( def raise_exceptions_from_results( - zwave_objects: Sequence[ZwaveNode | Endpoint], - results: Sequence[Any], + zwave_objects: Sequence[T], results: Sequence[Any] ) -> None: """Raise list of exceptions from a list of results.""" + errors: Sequence[tuple[T, Any]] if errors := [ tup for tup in zip(zwave_objects, results) if isinstance(tup[1], Exception) ]: @@ -93,6 +108,49 @@ def raise_exceptions_from_results( raise HomeAssistantError("\n".join(lines)) +async def _async_invoke_cc_api( + nodes_or_endpoints: set[T], + command_class: CommandClass, + method_name: str, + *args: Any, +) -> None: + """Invoke the CC API on a node endpoint.""" + nodes_or_endpoints_list = list(nodes_or_endpoints) + results = await asyncio.gather( + *( + node_or_endpoint.async_invoke_cc_api(command_class, method_name, *args) + for node_or_endpoint in nodes_or_endpoints_list + ), + return_exceptions=True, + ) + for node_or_endpoint, result in get_valid_responses_from_results( + nodes_or_endpoints_list, results + ): + if isinstance(node_or_endpoint, ZwaveNode): + _LOGGER.info( + ( + "Invoked %s CC API method %s on node %s with the following result: " + "%s" + ), + command_class.name, + method_name, + node_or_endpoint, + result, + ) + else: + _LOGGER.info( + ( + "Invoked %s CC API method %s on endpoint %s with the following " + "result: %s" + ), + command_class.name, + method_name, + node_or_endpoint, + result, + ) + raise_exceptions_from_results(nodes_or_endpoints_list, results) + + class ZWaveServices: """Class that holds our services (Zwave Commands). @@ -217,10 +275,19 @@ class ZWaveServices: vol.Required(const.ATTR_CONFIG_VALUE): vol.Any( vol.Coerce(int), BITMASK_SCHEMA, cv.string ), + vol.Inclusive(const.ATTR_VALUE_SIZE, "raw"): vol.All( + vol.Coerce(int), vol.Range(min=1, max=4), check_base_2 + ), + vol.Inclusive(const.ATTR_VALUE_FORMAT, "raw"): vol.Coerce( + ConfigurationValueFormat + ), }, cv.has_at_least_one_key( ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID ), + cv.has_at_most_one_key( + const.ATTR_CONFIG_PARAMETER_BITMASK, const.ATTR_VALUE_SIZE + ), parameter_name_does_not_need_bitmask, get_nodes_from_service_data, has_at_least_one_node, @@ -406,6 +473,34 @@ class ZWaveServices: ), ) + self._hass.services.async_register( + const.DOMAIN, + const.SERVICE_REFRESH_NOTIFICATIONS, + self.async_refresh_notifications, + schema=vol.Schema( + vol.All( + { + vol.Optional(ATTR_AREA_ID): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(ATTR_DEVICE_ID): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(const.ATTR_NOTIFICATION_TYPE): vol.All( + vol.Coerce(int), vol.Coerce(NotificationType) + ), + vol.Optional(const.ATTR_NOTIFICATION_EVENT): vol.Coerce(int), + }, + cv.has_at_least_one_key( + ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID + ), + get_nodes_from_service_data, + has_at_least_one_node, + ), + ), + ) + async def async_set_config_parameter(self, service: ServiceCall) -> None: """Set a config value on a node.""" nodes: set[ZwaveNode] = service.data[const.ATTR_NODES] @@ -413,7 +508,33 @@ class ZWaveServices: property_or_property_name = service.data[const.ATTR_CONFIG_PARAMETER] property_key = service.data.get(const.ATTR_CONFIG_PARAMETER_BITMASK) new_value = service.data[const.ATTR_CONFIG_VALUE] + value_size = service.data.get(const.ATTR_VALUE_SIZE) + value_format = service.data.get(const.ATTR_VALUE_FORMAT) + nodes_without_endpoints: set[ZwaveNode] = set() + # Remove nodes that don't have the specified endpoint + for node in nodes: + if endpoint not in node.endpoints: + nodes_without_endpoints.add(node) + nodes = nodes.difference(nodes_without_endpoints) + if not nodes: + raise HomeAssistantError( + "None of the specified nodes have the specified endpoint" + ) + if nodes_without_endpoints and _LOGGER.isEnabledFor(logging.WARNING): + _LOGGER.warning( + ( + "The following nodes do not have endpoint %x and will be " + "skipped: %s" + ), + endpoint, + nodes_without_endpoints, + ) + + # If value_size isn't provided, we will use the utility function which includes + # additional checks and protections. If it is provided, we will use the + # node.async_set_raw_config_parameter_value method which calls the + # Configuration CC set API. results = await asyncio.gather( *( async_set_config_parameter( @@ -423,23 +544,42 @@ class ZWaveServices: property_key=property_key, endpoint=endpoint, ) + if value_size is None + else node.endpoints[endpoint].async_set_raw_config_parameter_value( + new_value, + property_or_property_name, + property_key=property_key, + value_size=value_size, + value_format=value_format, + ) for node in nodes ), return_exceptions=True, ) - nodes_list = list(nodes) - for node, result in get_valid_responses_from_results(nodes_list, results): - zwave_value = result[0] - cmd_status = result[1] - if cmd_status == CommandStatus.ACCEPTED: - msg = "Set configuration parameter %s on Node %s with value %s" - else: - msg = ( - "Added command to queue to set configuration parameter %s on Node " - "%s with value %s. Parameter will be set when the device wakes up" - ) - _LOGGER.info(msg, zwave_value, node, new_value) - raise_exceptions_from_results(nodes_list, results) + + def process_results( + nodes_or_endpoints_list: list[T], _results: list[Any] + ) -> None: + """Process results for given nodes or endpoints.""" + for node_or_endpoint, result in get_valid_responses_from_results( + nodes_or_endpoints_list, _results + ): + zwave_value = result[0] + cmd_status = result[1] + if cmd_status.status == CommandStatus.ACCEPTED: + msg = "Set configuration parameter %s on Node %s with value %s" + else: + msg = ( + "Added command to queue to set configuration parameter %s on %s " + "with value %s. Parameter will be set when the device wakes up" + ) + _LOGGER.info(msg, zwave_value, node_or_endpoint, new_value) + raise_exceptions_from_results(nodes_or_endpoints_list, _results) + + if value_size is None: + process_results(list(nodes), results) + else: + process_results([node.endpoints[endpoint] for node in nodes], results) async def async_bulk_set_partial_config_parameters( self, service: ServiceCall @@ -531,7 +671,7 @@ class ZWaveServices: results = await asyncio.gather(*coros, return_exceptions=True) nodes_list = list(nodes) # multiple set_values my fail so we will track the entire list - set_value_failed_nodes_list: list[ZwaveNode | Endpoint] = [] + set_value_failed_nodes_list: list[ZwaveNode] = [] set_value_failed_error_list: list[SetValueFailed] = [] for node_, result in get_valid_responses_from_results(nodes_list, results): if result and result.status not in SET_VALUE_SUCCESS: @@ -643,38 +783,14 @@ class ZWaveServices: method_name: str = service.data[const.ATTR_METHOD_NAME] parameters: list[Any] = service.data[const.ATTR_PARAMETERS] - async def _async_invoke_cc_api(endpoints: set[Endpoint]) -> None: - """Invoke the CC API on a node endpoint.""" - results = await asyncio.gather( - *( - endpoint.async_invoke_cc_api( - command_class, method_name, *parameters - ) - for endpoint in endpoints - ), - return_exceptions=True, - ) - endpoints_list = list(endpoints) - for endpoint, result in get_valid_responses_from_results( - endpoints_list, results - ): - _LOGGER.info( - ( - "Invoked %s CC API method %s on endpoint %s with the following " - "result: %s" - ), - command_class.name, - method_name, - endpoint, - result, - ) - raise_exceptions_from_results(endpoints_list, results) - # If an endpoint is provided, we assume the user wants to call the CC API on # that endpoint for all target nodes if (endpoint := service.data.get(const.ATTR_ENDPOINT)) is not None: await _async_invoke_cc_api( - {node.endpoints[endpoint] for node in service.data[const.ATTR_NODES]} + {node.endpoints[endpoint] for node in service.data[const.ATTR_NODES]}, + command_class, + method_name, + *parameters, ) return @@ -723,4 +839,14 @@ class ZWaveServices: node.endpoints[endpoint_idx if endpoint_idx is not None else 0] ) - await _async_invoke_cc_api(endpoints) + await _async_invoke_cc_api(endpoints, command_class, method_name, *parameters) + + async def async_refresh_notifications(self, service: ServiceCall) -> None: + """Refresh notifications on a node.""" + nodes: set[ZwaveNode] = service.data[const.ATTR_NODES] + notification_type: NotificationType = service.data[const.ATTR_NOTIFICATION_TYPE] + notification_event: int | None = service.data.get(const.ATTR_NOTIFICATION_EVENT) + param: dict[str, int] = {"notificationType": notification_type.value} + if notification_event is not None: + param["notificationEvent"] = notification_event + await _async_invoke_cc_api(nodes, CommandClass.NOTIFICATION, "get", param) diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index e3d59ff43f7..81809e3fbeb 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -29,6 +29,65 @@ set_lock_usercode: selector: text: +set_lock_configuration: + target: + entity: + domain: lock + integration: zwave_js + fields: + operation_type: + required: true + example: timed + selector: + select: + options: + - constant + - timed + lock_timeout: + required: false + example: 1 + selector: + number: + min: 0 + max: 65535 + unit_of_measurement: sec + outside_handles_can_open_door_configuration: + required: false + example: [true, true, true, false] + selector: + object: + inside_handles_can_open_door_configuration: + required: false + example: [true, true, true, false] + selector: + object: + auto_relock_time: + required: false + example: 1 + selector: + number: + min: 0 + max: 65535 + unit_of_measurement: sec + hold_and_release_time: + required: false + example: 1 + selector: + number: + min: 0 + max: 65535 + unit_of_measurement: sec + twist_assist: + required: false + example: true + selector: + boolean: + block_to_block: + required: false + example: true + selector: + boolean: + set_config_parameter: target: entity: @@ -54,6 +113,18 @@ set_config_parameter: required: true selector: text: + value_size: + example: 1 + selector: + number: + min: 1 + max: 4 + value_format: + example: 1 + selector: + number: + min: 0 + max: 3 bulk_set_partial_config_parameters: target: @@ -223,3 +294,25 @@ invoke_cc_api: required: true selector: object: + +refresh_notifications: + target: + entity: + integration: zwave_js + fields: + notification_type: + example: 1 + required: true + selector: + number: + min: 1 + max: 22 + mode: box + notification_event: + example: 1 + required: false + selector: + number: + min: 1 + max: 255 + mode: box diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 4bb9494eb6b..19a47450080 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -216,11 +216,19 @@ }, "bitmask": { "name": "Bitmask", - "description": "Target a specific bitmask (see the documentation for more information)." + "description": "Target a specific bitmask (see the documentation for more information). Cannot be combined with value_size or value_format." }, "value": { "name": "Value", "description": "The new value to set for this configuration parameter." + }, + "value_size": { + "name": "Value size", + "description": "Size of the value, either 1, 2, or 4. Used in combination with value_format when a config parameter is not defined in your device's configuration file. Cannot be combined with bitmask." + }, + "value_format": { + "name": "Value format", + "description": "Format of the value, 0 for signed integer, 1 for unsigned integer, 2 for enumerated, 3 for bitfield. Used in combination with value_size when a config parameter is not defined in your device's configuration file. Cannot be combined with bitmask." } } }, @@ -363,6 +371,58 @@ "description": "A list of parameters to pass to the API method. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for parameters." } } + }, + "refresh_notifications": { + "name": "Refresh notifications on a node (advanced)", + "description": "Refreshes notifications on a node based on notification type and optionally notification event.", + "fields": { + "notification_type": { + "name": "Notification Type", + "description": "The Notification Type number as defined in the Z-Wave specs." + }, + "notification_event": { + "name": "Notification Event", + "description": "The Notification Event number as defined in the Z-Wave specs." + } + } + }, + "set_lock_configuration": { + "name": "Set lock configuration", + "description": "Sets the configuration for a lock.", + "fields": { + "operation_type": { + "name": "Operation Type", + "description": "The operation type of the lock." + }, + "lock_timeout": { + "name": "Lock timeout", + "description": "Seconds until lock mode times out. Should only be used if operation type is `timed`." + }, + "outside_handles_can_open_door_configuration": { + "name": "Outside handles can open door configuration", + "description": "A list of four booleans which indicate which outside handles can open the door." + }, + "inside_handles_can_open_door_configuration": { + "name": "Inside handles can open door configuration", + "description": "A list of four booleans which indicate which inside handles can open the door." + }, + "auto_relock_time": { + "name": "Auto relock time", + "description": "Duration in seconds until lock returns to secure state. Only enforced when operation type is `constant`." + }, + "hold_and_release_time": { + "name": "Hold and release time", + "description": "Duration in seconds the latch stays retracted." + }, + "twist_assist": { + "name": "Twist assist", + "description": "Enable Twist Assist." + }, + "block_to_block": { + "name": "Block to block", + "description": "Enable block-to-block functionality." + } + } } } } diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index e49eb8a2017..cf743a3e85a 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -333,6 +333,10 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): ) ) + # Make sure these variables are set for the elif evaluation + state = None + latest_version = None + # If we have a complete previous state, use that to set the latest version if ( (state := await self.async_get_last_state()) @@ -340,7 +344,8 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): is not None and (extra_data := await self.async_get_last_extra_data()) and ( - latest_version_firmware := ZWaveNodeFirmwareUpdateExtraStoredData.from_dict( + latest_version_firmware + := ZWaveNodeFirmwareUpdateExtraStoredData.from_dict( extra_data.as_dict() ).latest_version_firmware ) diff --git a/homeassistant/config.py b/homeassistant/config.py index 1b7e90996dc..b4850e372fd 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -4,18 +4,23 @@ from __future__ import annotations from collections import OrderedDict from collections.abc import Callable, Sequence from contextlib import suppress +from dataclasses import dataclass +from enum import StrEnum +from functools import reduce import logging +import operator import os from pathlib import Path import re import shutil from types import ModuleType -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import urlparse from awesomeversion import AwesomeVersion import voluptuous as vol -from voluptuous.humanize import humanize_error +from voluptuous.humanize import MAX_VALIDATION_ERROR_ITEM_LENGTH +from yaml.error import MarkedYAMLError from . import auth from .auth import mfa_modules as auth_mfa_modules, providers as auth_providers @@ -51,7 +56,7 @@ from .const import ( __version__, ) from .core import DOMAIN as CONF_CORE, ConfigSource, HomeAssistant, callback -from .exceptions import HomeAssistantError +from .exceptions import ConfigValidationError, HomeAssistantError from .generated.currencies import HISTORIC_CURRENCIES from .helpers import ( config_per_platform, @@ -69,7 +74,6 @@ from .util.yaml import SECRET_YAML, Secrets, load_yaml _LOGGER = logging.getLogger(__name__) -DATA_PERSISTENT_ERRORS = "bootstrap_persistent_errors" RE_YAML_ERROR = re.compile(r"homeassistant\.util\.yaml") RE_ASCII = re.compile(r"\033\[[^m]*m") YAML_CONFIG_FILE = "configuration.yaml" @@ -82,11 +86,7 @@ SCRIPT_CONFIG_PATH = "scripts.yaml" SCENE_CONFIG_PATH = "scenes.yaml" LOAD_EXCEPTIONS = (ImportError, FileNotFoundError) -INTEGRATION_LOAD_EXCEPTIONS = ( - IntegrationNotFound, - RequirementsNotFound, - *LOAD_EXCEPTIONS, -) +INTEGRATION_LOAD_EXCEPTIONS = (IntegrationNotFound, RequirementsNotFound) SAFE_MODE_FILENAME = "safe-mode" @@ -118,6 +118,46 @@ tts: """ +class ConfigErrorTranslationKey(StrEnum): + """Config error translation keys for config errors.""" + + # translation keys with a generated config related message text + CONFIG_VALIDATION_ERR = "config_validation_err" + PLATFORM_CONFIG_VALIDATION_ERR = "platform_config_validation_err" + + # translation keys with a general static message text + COMPONENT_IMPORT_ERR = "component_import_err" + CONFIG_PLATFORM_IMPORT_ERR = "config_platform_import_err" + CONFIG_VALIDATOR_UNKNOWN_ERR = "config_validator_unknown_err" + CONFIG_SCHEMA_UNKNOWN_ERR = "config_schema_unknown_err" + PLATFORM_VALIDATOR_UNKNOWN_ERR = "platform_validator_unknown_err" + PLATFORM_COMPONENT_LOAD_ERR = "platform_component_load_err" + PLATFORM_COMPONENT_LOAD_EXC = "platform_component_load_exc" + PLATFORM_SCHEMA_VALIDATOR_ERR = "platform_schema_validator_err" + + # translation key in case multiple errors occurred + INTEGRATION_CONFIG_ERROR = "integration_config_error" + + +@dataclass +class ConfigExceptionInfo: + """Configuration exception info class.""" + + exception: Exception + translation_key: ConfigErrorTranslationKey + platform_name: str + config: ConfigType + integration_link: str | None + + +@dataclass +class IntegrationConfigInfo: + """Configuration for an integration and exception information.""" + + config: ConfigType | None + exception_info_list: list[ConfigExceptionInfo] + + def _no_duplicate_auth_provider( configs: Sequence[dict[str, Any]] ) -> Sequence[dict[str, Any]]: @@ -395,12 +435,24 @@ async def async_hass_config_yaml(hass: HomeAssistant) -> dict: secrets = Secrets(Path(hass.config.config_dir)) # Not using async_add_executor_job because this is an internal method. - config = await hass.loop.run_in_executor( - None, - load_yaml_config_file, - hass.config.path(YAML_CONFIG_FILE), - secrets, - ) + try: + config = await hass.loop.run_in_executor( + None, + load_yaml_config_file, + hass.config.path(YAML_CONFIG_FILE), + secrets, + ) + except HomeAssistantError as exc: + if not (base_exc := exc.__cause__) or not isinstance(base_exc, MarkedYAMLError): + raise + + # Rewrite path to offending YAML file to be relative the hass config dir + if base_exc.context_mark and base_exc.context_mark.name: + base_exc.context_mark.name = _relpath(hass, base_exc.context_mark.name) + if base_exc.problem_mark and base_exc.problem_mark.name: + base_exc.problem_mark.name = _relpath(hass, base_exc.problem_mark.name) + raise + core_config = config.get(CONF_CORE, {}) await merge_packages_config(hass, config, core_config.get(CONF_PACKAGES, {})) return config @@ -488,60 +540,222 @@ def process_ha_config_upgrade(hass: HomeAssistant) -> None: @callback -def async_log_exception( - ex: Exception, +def async_log_schema_error( + exc: vol.Invalid, domain: str, config: dict, hass: HomeAssistant, link: str | None = None, ) -> None: - """Log an error for configuration validation. - - This method must be run in the event loop. - """ - if hass is not None: - async_notify_setup_error(hass, domain, link) - message, is_friendly = _format_config_error(ex, domain, config, link) - _LOGGER.error(message, exc_info=not is_friendly and ex) + """Log a schema validation error.""" + message = format_schema_error(hass, exc, domain, config, link) + _LOGGER.error(message) @callback -def _format_config_error( - ex: Exception, domain: str, config: dict, link: str | None = None -) -> tuple[str, bool]: - """Generate log exception for configuration validation. +def async_log_config_validator_error( + exc: vol.Invalid | HomeAssistantError, + domain: str, + config: dict, + hass: HomeAssistant, + link: str | None = None, +) -> None: + """Log an error from a custom config validator.""" + if isinstance(exc, vol.Invalid): + async_log_schema_error(exc, domain, config, hass, link) + return - This method must be run in the event loop. + message = format_homeassistant_error(hass, exc, domain, config, link) + _LOGGER.error(message, exc_info=exc) + + +def _get_annotation(item: Any) -> tuple[str, int | str] | None: + if not hasattr(item, "__config_file__"): + return None + + return (getattr(item, "__config_file__"), getattr(item, "__line__", "?")) + + +def _get_by_path(data: dict | list, items: list[str | int]) -> Any: + """Access a nested object in root by item sequence. + + Returns None in case of error. """ - is_friendly = False - message = f"Invalid config for [{domain}]: " - if isinstance(ex, vol.Invalid): - if "extra keys not allowed" in ex.error_message: - path = "->".join(str(m) for m in ex.path) - message += ( - f"[{ex.path[-1]}] is an invalid option for [{domain}]. " - f"Check: {domain}->{path}." - ) - else: - message += f"{humanize_error(config, ex)}." - is_friendly = True - else: - message += str(ex) or repr(ex) - try: - domain_config = config.get(domain, config) - except AttributeError: - domain_config = config + return reduce(operator.getitem, items, data) # type: ignore[arg-type] + except (KeyError, IndexError, TypeError): + return None - message += ( - f" (See {getattr(domain_config, '__config_file__', '?')}, " - f"line {getattr(domain_config, '__line__', '?')}). " + +def find_annotation( + config: dict | list, path: list[str | int] +) -> tuple[str, int | str] | None: + """Find file/line annotation for a node in config pointed to by path. + + If the node pointed to is a dict or list, prefer the annotation for the key in + the key/value pair defining the dict or list. + If the node is not annotated, try the parent node. + """ + + def find_annotation_for_key( + item: dict, path: list[str | int], tail: str | int + ) -> tuple[str, int | str] | None: + for key in item: + if key == tail: + if annotation := _get_annotation(key): + return annotation + break + return None + + def find_annotation_rec( + config: dict | list, path: list[str | int], tail: str | int | None + ) -> tuple[str, int | str] | None: + item = _get_by_path(config, path) + if isinstance(item, dict) and tail is not None: + if tail_annotation := find_annotation_for_key(item, path, tail): + return tail_annotation + + if ( + isinstance(item, (dict, list)) + and path + and ( + key_annotation := find_annotation_for_key( + _get_by_path(config, path[:-1]), path[:-1], path[-1] + ) + ) + ): + return key_annotation + + if annotation := _get_annotation(item): + return annotation + + if not path: + return None + + tail = path.pop() + if annotation := find_annotation_rec(config, path, tail): + return annotation + return _get_annotation(item) + + return find_annotation_rec(config, list(path), None) + + +def _relpath(hass: HomeAssistant, path: str) -> str: + """Return path relative to the Home Assistant config dir.""" + return os.path.relpath(path, hass.config.config_dir) + + +def stringify_invalid( + hass: HomeAssistant, + exc: vol.Invalid, + domain: str, + config: dict, + link: str | None, + max_sub_error_length: int, +) -> str: + """Stringify voluptuous.Invalid. + + This is an alternative to the custom __str__ implemented in + voluptuous.error.Invalid. The modifications are: + - Format the path delimited by -> instead of @data[] + - Prefix with domain, file and line of the error + - Suffix with a link to the documentation + - Give a more user friendly output for unknown options + - Give a more user friendly output for missing options + """ + message_prefix = f"Invalid config for '{domain}'" + if domain != CONF_CORE and link: + message_suffix = f", please check the docs at {link}" + else: + message_suffix = "" + if annotation := find_annotation(config, exc.path): + message_prefix += f" at {_relpath(hass, annotation[0])}, line {annotation[1]}" + path = "->".join(str(m) for m in exc.path) + if exc.error_message == "extra keys not allowed": + return ( + f"{message_prefix}: '{exc.path[-1]}' is an invalid option for '{domain}', " + f"check: {path}{message_suffix}" + ) + if exc.error_message == "required key not provided": + return ( + f"{message_prefix}: required key '{exc.path[-1]}' not provided" + f"{message_suffix}" + ) + # This function is an alternative to the stringification done by + # vol.Invalid.__str__, so we need to call Exception.__str__ here + # instead of str(exc) + output = Exception.__str__(exc) + if error_type := exc.error_type: + output += " for " + error_type + offending_item_summary = repr(_get_by_path(config, exc.path)) + if len(offending_item_summary) > max_sub_error_length: + offending_item_summary = ( + f"{offending_item_summary[: max_sub_error_length - 3]}..." + ) + return ( + f"{message_prefix}: {output} '{path}', got {offending_item_summary}" + f"{message_suffix}" ) - if domain != CONF_CORE and link: - message += f"Please check the docs at {link}" - return message, is_friendly +def humanize_error( + hass: HomeAssistant, + validation_error: vol.Invalid, + domain: str, + config: dict, + link: str | None, + max_sub_error_length: int = MAX_VALIDATION_ERROR_ITEM_LENGTH, +) -> str: + """Provide a more helpful + complete validation error message. + + This is a modified version of voluptuous.error.Invalid.__str__, + the modifications make some minor changes to the formatting. + """ + if isinstance(validation_error, vol.MultipleInvalid): + return "\n".join( + sorted( + humanize_error( + hass, sub_error, domain, config, link, max_sub_error_length + ) + for sub_error in validation_error.errors + ) + ) + return stringify_invalid( + hass, validation_error, domain, config, link, max_sub_error_length + ) + + +@callback +def format_homeassistant_error( + hass: HomeAssistant, + exc: HomeAssistantError, + domain: str, + config: dict, + link: str | None = None, +) -> str: + """Format HomeAssistantError thrown by a custom config validator.""" + message_prefix = f"Invalid config for '{domain}'" + # HomeAssistantError raised by custom config validator has no path to the + # offending configuration key, use the domain key as path instead. + if annotation := find_annotation(config, [domain]): + message_prefix += f" at {_relpath(hass, annotation[0])}, line {annotation[1]}" + message = f"{message_prefix}: {str(exc) or repr(exc)}" + if domain != CONF_CORE and link: + message += f", please check the docs at {link}" + + return message + + +@callback +def format_schema_error( + hass: HomeAssistant, + exc: vol.Invalid, + domain: str, + config: dict, + link: str | None = None, +) -> str: + """Format configuration validation error.""" + return humanize_error(hass, exc, domain, config, link) async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> None: @@ -663,17 +877,15 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non hac.units = get_unit_system(config[CONF_UNIT_SYSTEM]) -def _log_pkg_error(package: str, component: str, config: dict, message: str) -> None: +def _log_pkg_error( + hass: HomeAssistant, package: str, component: str, config: dict, message: str +) -> None: """Log an error while merging packages.""" - message = f"Package {package} setup failed. Integration {component} {message}" + message_prefix = f"Setup of package '{package}'" + if annotation := find_annotation(config, [CONF_CORE, CONF_PACKAGES, package]): + message_prefix += f" at {_relpath(hass, annotation[0])}, line {annotation[1]}" - pack_config = config[CONF_CORE][CONF_PACKAGES].get(package, config) - message += ( - f" (See {getattr(pack_config, '__config_file__', '?')}:" - f"{getattr(pack_config, '__line__', '?')}). " - ) - - _LOGGER.error(message) + _LOGGER.error("%s failed: %s", message_prefix, message) def _identify_config_schema(module: ComponentProtocol) -> str | None: @@ -724,15 +936,15 @@ def _identify_config_schema(module: ComponentProtocol) -> str | None: return None -def _recursive_merge(conf: dict[str, Any], package: dict[str, Any]) -> bool | str: +def _recursive_merge(conf: dict[str, Any], package: dict[str, Any]) -> str | None: """Merge package into conf, recursively.""" - error: bool | str = False + duplicate_key: str | None = None for key, pack_conf in package.items(): if isinstance(pack_conf, dict): if not pack_conf: continue conf[key] = conf.get(key, OrderedDict()) - error = _recursive_merge(conf=conf[key], package=pack_conf) + duplicate_key = _recursive_merge(conf=conf[key], package=pack_conf) elif isinstance(pack_conf, list): conf[key] = cv.remove_falsy( @@ -743,14 +955,16 @@ def _recursive_merge(conf: dict[str, Any], package: dict[str, Any]) -> bool | st if conf.get(key) is not None: return key conf[key] = pack_conf - return error + return duplicate_key async def merge_packages_config( hass: HomeAssistant, config: dict, packages: dict[str, Any], - _log_pkg_error: Callable = _log_pkg_error, + _log_pkg_error: Callable[ + [HomeAssistant, str, str, dict, str], None + ] = _log_pkg_error, ) -> dict: """Merge packages into the top-level configuration. Mutate config.""" PACKAGES_CONFIG_SCHEMA(packages) @@ -767,8 +981,17 @@ async def merge_packages_config( hass, domain ) component = integration.get_component() - except INTEGRATION_LOAD_EXCEPTIONS as ex: - _log_pkg_error(pack_name, comp_name, config, str(ex)) + except LOAD_EXCEPTIONS as exc: + _log_pkg_error( + hass, + pack_name, + comp_name, + config, + f"Integration {comp_name} caused error: {str(exc)}", + ) + continue + except INTEGRATION_LOAD_EXCEPTIONS as exc: + _log_pkg_error(hass, pack_name, comp_name, config, str(exc)) continue try: @@ -802,7 +1025,11 @@ async def merge_packages_config( if not isinstance(comp_conf, dict): _log_pkg_error( - pack_name, comp_name, config, "cannot be merged. Expected a dict." + hass, + pack_name, + comp_name, + config, + f"integration '{comp_name}' cannot be merged, expected a dict", ) continue @@ -811,37 +1038,217 @@ async def merge_packages_config( if not isinstance(config[comp_name], dict): _log_pkg_error( + hass, pack_name, comp_name, config, - "cannot be merged. Dict expected in main config.", + ( + f"integration '{comp_name}' cannot be merged, dict expected in " + "main config" + ), ) continue - error = _recursive_merge(conf=config[comp_name], package=comp_conf) - if error: + duplicate_key = _recursive_merge(conf=config[comp_name], package=comp_conf) + if duplicate_key: _log_pkg_error( - pack_name, comp_name, config, f"has duplicate key '{error}'" + hass, + pack_name, + comp_name, + config, + f"integration '{comp_name}' has duplicate key '{duplicate_key}'", ) return config -async def async_process_component_config( # noqa: C901 - hass: HomeAssistant, config: ConfigType, integration: Integration -) -> ConfigType | None: - """Check component configuration and return processed configuration. +@callback +def _get_log_message_and_stack_print_pref( + hass: HomeAssistant, domain: str, platform_exception: ConfigExceptionInfo +) -> tuple[str | None, bool, dict[str, str]]: + """Get message to log and print stack trace preference.""" + exception = platform_exception.exception + platform_name = platform_exception.platform_name + platform_config = platform_exception.config + link = platform_exception.integration_link - Returns None on error. + placeholders: dict[str, str] = {"domain": domain, "error": str(exception)} + + log_message_mapping: dict[ConfigErrorTranslationKey, tuple[str, bool]] = { + ConfigErrorTranslationKey.COMPONENT_IMPORT_ERR: ( + f"Unable to import {domain}: {exception}", + False, + ), + ConfigErrorTranslationKey.CONFIG_PLATFORM_IMPORT_ERR: ( + f"Error importing config platform {domain}: {exception}", + False, + ), + ConfigErrorTranslationKey.CONFIG_VALIDATOR_UNKNOWN_ERR: ( + f"Unknown error calling {domain} config validator", + True, + ), + ConfigErrorTranslationKey.CONFIG_SCHEMA_UNKNOWN_ERR: ( + f"Unknown error calling {domain} CONFIG_SCHEMA", + True, + ), + ConfigErrorTranslationKey.PLATFORM_VALIDATOR_UNKNOWN_ERR: ( + f"Unknown error validating {platform_name} platform config with {domain} " + "component platform schema", + True, + ), + ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_ERR: ( + f"Platform error: {domain} - {exception}", + False, + ), + ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_EXC: ( + f"Platform error: {domain} - {exception}", + True, + ), + ConfigErrorTranslationKey.PLATFORM_SCHEMA_VALIDATOR_ERR: ( + f"Unknown error validating config for {platform_name} platform " + f"for {domain} component with PLATFORM_SCHEMA", + True, + ), + } + log_message_show_stack_trace = log_message_mapping.get( + platform_exception.translation_key + ) + if log_message_show_stack_trace is None: + # If no pre defined log_message is set, we generate an enriched error + # message, so we can notify about it during setup + show_stack_trace = False + if isinstance(exception, vol.Invalid): + log_message = format_schema_error( + hass, exception, platform_name, platform_config, link + ) + if annotation := find_annotation(platform_config, exception.path): + placeholders["config_file"], line = annotation + placeholders["line"] = str(line) + else: + if TYPE_CHECKING: + assert isinstance(exception, HomeAssistantError) + log_message = format_homeassistant_error( + hass, exception, platform_name, platform_config, link + ) + if annotation := find_annotation(platform_config, [platform_name]): + placeholders["config_file"], line = annotation + placeholders["line"] = str(line) + show_stack_trace = True + return (log_message, show_stack_trace, placeholders) + + assert isinstance(log_message_show_stack_trace, tuple) + + return (*log_message_show_stack_trace, placeholders) + + +async def async_process_component_and_handle_errors( + hass: HomeAssistant, + config: ConfigType, + integration: Integration, + raise_on_failure: bool = False, +) -> ConfigType | None: + """Process and component configuration and handle errors. + + In case of errors: + - Print the error messages to the log. + - Raise a ConfigValidationError if raise_on_failure is set. + + Returns the integration config or `None`. + """ + integration_config_info = await async_process_component_config( + hass, config, integration + ) + return async_handle_component_errors( + hass, integration_config_info, integration, raise_on_failure + ) + + +@callback +def async_handle_component_errors( + hass: HomeAssistant, + integration_config_info: IntegrationConfigInfo, + integration: Integration, + raise_on_failure: bool = False, +) -> ConfigType | None: + """Handle component configuration errors from async_process_component_config. + + In case of errors: + - Print the error messages to the log. + - Raise a ConfigValidationError if raise_on_failure is set. + + Returns the integration config or `None`. + """ + + if not (config_exception_info := integration_config_info.exception_info_list): + return integration_config_info.config + + platform_exception: ConfigExceptionInfo + domain = integration.domain + placeholders: dict[str, str] + for platform_exception in config_exception_info: + exception = platform_exception.exception + ( + log_message, + show_stack_trace, + placeholders, + ) = _get_log_message_and_stack_print_pref(hass, domain, platform_exception) + _LOGGER.error( + log_message, + exc_info=exception if show_stack_trace else None, + ) + + if not raise_on_failure: + return integration_config_info.config + + if len(config_exception_info) == 1: + translation_key = platform_exception.translation_key + else: + translation_key = ConfigErrorTranslationKey.INTEGRATION_CONFIG_ERROR + errors = str(len(config_exception_info)) + log_message = ( + f"Failed to process component config for integration {domain} " + f"due to multiple errors ({errors}), check the logs for more information." + ) + placeholders = { + "domain": domain, + "errors": errors, + } + raise ConfigValidationError( + str(log_message), + [platform_exception.exception for platform_exception in config_exception_info], + translation_domain="homeassistant", + translation_key=translation_key, + translation_placeholders=placeholders, + ) + + +async def async_process_component_config( # noqa: C901 + hass: HomeAssistant, + config: ConfigType, + integration: Integration, +) -> IntegrationConfigInfo: + """Check component configuration. + + Returns processed configuration and exception information. This method must be run in the event loop. """ domain = integration.domain + integration_docs = integration.documentation + config_exceptions: list[ConfigExceptionInfo] = [] + try: component = integration.get_component() - except LOAD_EXCEPTIONS as ex: - _LOGGER.error("Unable to import %s: %s", domain, ex) - return None + except LOAD_EXCEPTIONS as exc: + exc_info = ConfigExceptionInfo( + exc, + ConfigErrorTranslationKey.COMPONENT_IMPORT_ERR, + domain, + config, + integration_docs, + ) + config_exceptions.append(exc_info) + return IntegrationConfigInfo(None, config_exceptions) # Check if the integration has a custom config validator config_validator = None @@ -852,58 +1259,101 @@ async def async_process_component_config( # noqa: C901 # If the config platform contains bad imports, make sure # that still fails. if err.name != f"{integration.pkg_path}.config": - _LOGGER.error("Error importing config platform %s: %s", domain, err) - return None + exc_info = ConfigExceptionInfo( + err, + ConfigErrorTranslationKey.CONFIG_PLATFORM_IMPORT_ERR, + domain, + config, + integration_docs, + ) + config_exceptions.append(exc_info) + return IntegrationConfigInfo(None, config_exceptions) if config_validator is not None and hasattr( config_validator, "async_validate_config" ): try: - return ( # type: ignore[no-any-return] - await config_validator.async_validate_config(hass, config) + return IntegrationConfigInfo( + await config_validator.async_validate_config(hass, config), [] ) - except (vol.Invalid, HomeAssistantError) as ex: - async_log_exception(ex, domain, config, hass, integration.documentation) - return None - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unknown error calling %s config validator", domain) - return None + except (vol.Invalid, HomeAssistantError) as exc: + exc_info = ConfigExceptionInfo( + exc, + ConfigErrorTranslationKey.CONFIG_VALIDATION_ERR, + domain, + config, + integration_docs, + ) + config_exceptions.append(exc_info) + return IntegrationConfigInfo(None, config_exceptions) + except Exception as exc: # pylint: disable=broad-except + exc_info = ConfigExceptionInfo( + exc, + ConfigErrorTranslationKey.CONFIG_VALIDATOR_UNKNOWN_ERR, + domain, + config, + integration_docs, + ) + config_exceptions.append(exc_info) + return IntegrationConfigInfo(None, config_exceptions) # No custom config validator, proceed with schema validation if hasattr(component, "CONFIG_SCHEMA"): try: - return component.CONFIG_SCHEMA(config) # type: ignore[no-any-return] - except vol.Invalid as ex: - async_log_exception(ex, domain, config, hass, integration.documentation) - return None - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unknown error calling %s CONFIG_SCHEMA", domain) - return None + return IntegrationConfigInfo(component.CONFIG_SCHEMA(config), []) + except vol.Invalid as exc: + exc_info = ConfigExceptionInfo( + exc, + ConfigErrorTranslationKey.CONFIG_VALIDATION_ERR, + domain, + config, + integration_docs, + ) + config_exceptions.append(exc_info) + return IntegrationConfigInfo(None, config_exceptions) + except Exception as exc: # pylint: disable=broad-except + exc_info = ConfigExceptionInfo( + exc, + ConfigErrorTranslationKey.CONFIG_SCHEMA_UNKNOWN_ERR, + domain, + config, + integration_docs, + ) + config_exceptions.append(exc_info) + return IntegrationConfigInfo(None, config_exceptions) component_platform_schema = getattr( component, "PLATFORM_SCHEMA_BASE", getattr(component, "PLATFORM_SCHEMA", None) ) if component_platform_schema is None: - return config + return IntegrationConfigInfo(config, []) - platforms = [] + platforms: list[ConfigType] = [] for p_name, p_config in config_per_platform(config, domain): # Validate component specific platform schema + platform_name = f"{domain}.{p_name}" try: p_validated = component_platform_schema(p_config) - except vol.Invalid as ex: - async_log_exception(ex, domain, p_config, hass, integration.documentation) - continue - except Exception: # pylint: disable=broad-except - _LOGGER.exception( - ( - "Unknown error validating %s platform config with %s component" - " platform schema" - ), - p_name, + except vol.Invalid as exc: + exc_info = ConfigExceptionInfo( + exc, + ConfigErrorTranslationKey.PLATFORM_CONFIG_VALIDATION_ERR, domain, + p_config, + integration_docs, ) + config_exceptions.append(exc_info) + continue + except Exception as exc: # pylint: disable=broad-except + exc_info = ConfigExceptionInfo( + exc, + ConfigErrorTranslationKey.PLATFORM_SCHEMA_VALIDATOR_ERR, + str(p_name), + config, + integration_docs, + ) + config_exceptions.append(exc_info) continue # Not all platform components follow same pattern for platforms @@ -915,38 +1365,53 @@ async def async_process_component_config( # noqa: C901 try: p_integration = await async_get_integration_with_requirements(hass, p_name) - except (RequirementsNotFound, IntegrationNotFound) as ex: - _LOGGER.error("Platform error: %s - %s", domain, ex) + except (RequirementsNotFound, IntegrationNotFound) as exc: + exc_info = ConfigExceptionInfo( + exc, + ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_ERR, + platform_name, + p_config, + integration_docs, + ) + config_exceptions.append(exc_info) continue try: platform = p_integration.get_platform(domain) - except LOAD_EXCEPTIONS: - _LOGGER.exception("Platform error: %s", domain) + except LOAD_EXCEPTIONS as exc: + exc_info = ConfigExceptionInfo( + exc, + ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_EXC, + platform_name, + p_config, + integration_docs, + ) + config_exceptions.append(exc_info) continue # Validate platform specific schema if hasattr(platform, "PLATFORM_SCHEMA"): try: p_validated = platform.PLATFORM_SCHEMA(p_config) - except vol.Invalid as ex: - async_log_exception( - ex, - f"{domain}.{p_name}", + except vol.Invalid as exc: + exc_info = ConfigExceptionInfo( + exc, + ConfigErrorTranslationKey.PLATFORM_CONFIG_VALIDATION_ERR, + platform_name, p_config, - hass, p_integration.documentation, ) + config_exceptions.append(exc_info) continue - except Exception: # pylint: disable=broad-except - _LOGGER.exception( - ( - "Unknown error validating config for %s platform for %s" - " component with PLATFORM_SCHEMA" - ), + except Exception as exc: # pylint: disable=broad-except + exc_info = ConfigExceptionInfo( + exc, + ConfigErrorTranslationKey.PLATFORM_SCHEMA_VALIDATOR_ERR, p_name, - domain, + p_config, + p_integration.documentation, ) + config_exceptions.append(exc_info) continue platforms.append(p_validated) @@ -956,7 +1421,7 @@ async def async_process_component_config( # noqa: C901 config = config_without_domain(config, domain) config[domain] = platforms - return config + return IntegrationConfigInfo(config, config_exceptions) @callback @@ -981,36 +1446,6 @@ async def async_check_ha_config_file(hass: HomeAssistant) -> str | None: return res.error_str -@callback -def async_notify_setup_error( - hass: HomeAssistant, component: str, display_link: str | None = None -) -> None: - """Print a persistent notification. - - This method must be run in the event loop. - """ - # pylint: disable-next=import-outside-toplevel - from .components import persistent_notification - - if (errors := hass.data.get(DATA_PERSISTENT_ERRORS)) is None: - errors = hass.data[DATA_PERSISTENT_ERRORS] = {} - - errors[component] = errors.get(component) or display_link - - message = "The following integrations and platforms could not be set up:\n\n" - - for name, link in errors.items(): - show_logs = f"[Show logs](/config/logs?filter={name})" - part = f"[{name}]({link})" if link else name - message += f" - {part} ({show_logs})\n" - - message += "\nPlease check your config and [logs](/config/logs)." - - persistent_notification.async_create( - hass, message, "Invalid config", "invalid_config" - ) - - def safe_mode_enabled(config_dir: str) -> bool: """Return if safe mode is enabled. diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 2b8f1ec4065..756b2def581 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -406,8 +406,8 @@ class ConfigEntry: "%s.async_setup_entry did not return boolean", integration.domain ) result = False - except ConfigEntryError as ex: - error_reason = str(ex) or "Unknown fatal config entry error" + except ConfigEntryError as exc: + error_reason = str(exc) or "Unknown fatal config entry error" _LOGGER.exception( "Error setting up entry %s for %s: %s", self.title, @@ -416,8 +416,8 @@ class ConfigEntry: ) await self._async_process_on_unload(hass) result = False - except ConfigEntryAuthFailed as ex: - message = str(ex) + except ConfigEntryAuthFailed as exc: + message = str(exc) auth_base_message = "could not authenticate" error_reason = message or auth_base_message auth_message = ( @@ -432,13 +432,13 @@ class ConfigEntry: await self._async_process_on_unload(hass) self.async_start_reauth(hass) result = False - except ConfigEntryNotReady as ex: - self._async_set_state(hass, ConfigEntryState.SETUP_RETRY, str(ex) or None) + except ConfigEntryNotReady as exc: + self._async_set_state(hass, ConfigEntryState.SETUP_RETRY, str(exc) or None) wait_time = 2 ** min(self._tries, 4) * 5 + ( randint(RANDOM_MICROSECOND_MIN, RANDOM_MICROSECOND_MAX) / 1000000 ) self._tries += 1 - message = str(ex) + message = str(exc) ready_message = f"ready yet: {message}" if message else "ready yet" _LOGGER.debug( ( @@ -565,13 +565,13 @@ class ConfigEntry: await self._async_process_on_unload(hass) return result - except Exception as ex: # pylint: disable=broad-except + except Exception as exc: # pylint: disable=broad-except _LOGGER.exception( "Error unloading entry %s for %s", self.title, integration.domain ) if integration.domain == self.domain: self._async_set_state( - hass, ConfigEntryState.FAILED_UNLOAD, str(ex) or "Unknown error" + hass, ConfigEntryState.FAILED_UNLOAD, str(exc) or "Unknown error" ) return False diff --git a/homeassistant/const.py b/homeassistant/const.py index c03087dc10f..8267fd29390 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -6,8 +6,8 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 11 -PATCH_VERSION: Final = "3" +MINOR_VERSION: Final = 12 +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/homeassistant/core.py b/homeassistant/core.py index 2025d813be4..7d9d8d19b49 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -80,7 +80,6 @@ from .exceptions import ( ServiceNotFound, Unauthorized, ) -from .helpers.aiohttp_compat import restore_original_aiohttp_cancel_behavior from .helpers.json import json_dumps from .util import dt as dt_util, location from .util.async_ import ( @@ -91,7 +90,7 @@ from .util.async_ import ( from .util.json import JsonObjectType from .util.read_only_dict import ReadOnlyDict from .util.timeout import TimeoutManager -from .util.ulid import ulid, ulid_at_time +from .util.ulid import ulid_at_time, ulid_now from .util.unit_system import ( _CONF_UNIT_SYSTEM_IMPERIAL, _CONF_UNIT_SYSTEM_US_CUSTOMARY, @@ -113,7 +112,6 @@ STAGE_2_SHUTDOWN_TIMEOUT = 60 STAGE_3_SHUTDOWN_TIMEOUT = 30 block_async_io.enable() -restore_original_aiohttp_cancel_behavior() _T = TypeVar("_T") _R = TypeVar("_R") @@ -134,6 +132,7 @@ DOMAIN = "homeassistant" BLOCK_LOG_TIMEOUT = 60 ServiceResponse = JsonObjectType | None +EntityServiceResponse = dict[str, ServiceResponse] class ConfigSource(enum.StrEnum): @@ -209,6 +208,18 @@ def is_callback(func: Callable[..., Any]) -> bool: return getattr(func, "_hass_callback", False) is True +def is_callback_check_partial(target: Callable[..., Any]) -> bool: + """Check if function is safe to be called in the event loop. + + This version of is_callback will also check if the target is a partial + and walk the chain of partials to find the original function. + """ + check_target = target + while isinstance(check_target, functools.partial): + check_target = check_target.func + return is_callback(check_target) + + class _Hass(threading.local): """Container which makes a HomeAssistant instance available to the event loop.""" @@ -270,11 +281,12 @@ class HassJob(Generic[_P, _R_co]): name: str | None = None, *, cancel_on_shutdown: bool | None = None, + job_type: HassJobType | None = None, ) -> None: """Create a job object.""" self.target = target self.name = name - self.job_type = _get_hassjob_callable_job_type(target) + self.job_type = job_type or _get_hassjob_callable_job_type(target) self._cancel_on_shutdown = cancel_on_shutdown @property @@ -860,8 +872,10 @@ class HomeAssistant: _LOGGER.exception( "Task %s could not be canceled during stage 3 shutdown", task ) - except Exception as ex: # pylint: disable=broad-except - _LOGGER.exception("Task %s error during stage 3 shutdown: %s", task, ex) + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception( + "Task %s error during stage 3 shutdown: %s", task, exc + ) # Prevent run_callback_threadsafe from scheduling any additional # callbacks in the event loop as callbacks created on the futures @@ -916,7 +930,7 @@ class Context: id: str | None = None, # pylint: disable=redefined-builtin ) -> None: """Init the context.""" - self.id = id or ulid() + self.id = id or ulid_now() self.user_id = user_id self.parent_id = parent_id self.origin_event: Event | None = None @@ -1141,13 +1155,20 @@ class EventBus: This method must be run in the event loop. """ - if event_filter is not None and not is_callback(event_filter): + job_type: HassJobType | None = None + if event_filter is not None and not is_callback_check_partial(event_filter): raise HomeAssistantError(f"Event filter {event_filter} is not a callback") - if run_immediately and not is_callback(listener): - raise HomeAssistantError(f"Event listener {listener} is not a callback") + if run_immediately: + if not is_callback_check_partial(listener): + raise HomeAssistantError(f"Event listener {listener} is not a callback") + job_type = HassJobType.Callback return self._async_listen_filterable_job( event_type, - (HassJob(listener, f"listen {event_type}"), event_filter, run_immediately), + ( + HassJob(listener, f"listen {event_type}", job_type=job_type), + event_filter, + run_immediately, + ), ) @callback @@ -1155,12 +1176,9 @@ class EventBus: self, event_type: str, filterable_job: _FilterableJobType ) -> CALLBACK_TYPE: self._listeners.setdefault(event_type, []).append(filterable_job) - - def remove_listener() -> None: - """Remove the listener.""" - self._async_remove_listener(event_type, filterable_job) - - return remove_listener + return functools.partial( + self._async_remove_listener, event_type, filterable_job + ) def listen_once( self, @@ -1222,7 +1240,11 @@ class EventBus: ) filterable_job = ( - HassJob(_onetime_listener, f"onetime listen {event_type} {listener}"), + HassJob( + _onetime_listener, + f"onetime listen {event_type} {listener}", + job_type=HassJobType.Callback, + ), None, False, ) @@ -1749,7 +1771,13 @@ class Service: def __init__( self, - func: Callable[[ServiceCall], Coroutine[Any, Any, ServiceResponse] | None], + func: Callable[ + [ServiceCall], + Coroutine[Any, Any, ServiceResponse | EntityServiceResponse] + | ServiceResponse + | EntityServiceResponse + | None, + ], schema: vol.Schema | None, domain: str, service: str, @@ -1840,7 +1868,7 @@ class ServiceRegistry: service: str, service_func: Callable[ [ServiceCall], - Coroutine[Any, Any, ServiceResponse] | None, + Coroutine[Any, Any, ServiceResponse] | ServiceResponse | None, ], schema: vol.Schema | None = None, ) -> None: @@ -1858,7 +1886,11 @@ class ServiceRegistry: domain: str, service: str, service_func: Callable[ - [ServiceCall], Coroutine[Any, Any, ServiceResponse] | None + [ServiceCall], + Coroutine[Any, Any, ServiceResponse | EntityServiceResponse] + | ServiceResponse + | EntityServiceResponse + | None, ], schema: vol.Schema | None = None, supports_response: SupportsResponse = SupportsResponse.NONE, diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 2946c8c3743..8d5e2bbde95 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -12,6 +12,48 @@ if TYPE_CHECKING: class HomeAssistantError(Exception): """General Home Assistant exception occurred.""" + def __init__( + self, + *args: object, + translation_domain: str | None = None, + translation_key: str | None = None, + translation_placeholders: dict[str, str] | None = None, + ) -> None: + """Initialize exception.""" + super().__init__(*args) + self.translation_domain = translation_domain + self.translation_key = translation_key + self.translation_placeholders = translation_placeholders + + +class ConfigValidationError(HomeAssistantError, ExceptionGroup[Exception]): + """A validation exception occurred when validating the configuration.""" + + def __init__( + self, + message: str, + exceptions: list[Exception], + translation_domain: str | None = None, + translation_key: str | None = None, + translation_placeholders: dict[str, str] | None = None, + ) -> None: + """Initialize exception.""" + super().__init__( + *(message, exceptions), + translation_domain=translation_domain, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + self._message = message + + def __str__(self) -> str: + """Return exception message string.""" + return self._message + + +class ServiceValidationError(HomeAssistantError): + """A validation exception occurred when calling a service.""" + class InvalidEntityFormatError(HomeAssistantError): """When an invalid formatted entity is encountered.""" @@ -165,13 +207,19 @@ class ServiceNotFound(HomeAssistantError): def __init__(self, domain: str, service: str) -> None: """Initialize error.""" - super().__init__(self, f"Service {domain}.{service} not found") + super().__init__( + self, + f"Service {domain}.{service} not found.", + translation_domain="homeassistant", + translation_key="service_not_found", + translation_placeholders={"domain": domain, "service": service}, + ) self.domain = domain self.service = service def __str__(self) -> str: """Return string representation.""" - return f"Unable to find service {self.domain}.{self.service}" + return f"Service {self.domain}.{self.service} not found." class MaxLengthExceeded(HomeAssistantError): diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 48864fef3af..eeee6532792 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -78,6 +78,7 @@ FLOWS = { "bsblan", "bthome", "buienradar", + "caldav", "canary", "cast", "cert_expiry", @@ -94,6 +95,7 @@ FLOWS = { "deconz", "deluge", "denonavr", + "devialet", "devolo_home_control", "devolo_home_network", "dexcom", @@ -140,6 +142,7 @@ FLOWS = { "evil_genius_labs", "ezviz", "faa_delays", + "fastdotcom", "fibaro", "filesize", "fireservicerota", @@ -259,6 +262,7 @@ FLOWS = { "lidarr", "life360", "lifx", + "linear_garage_door", "litejet", "litterrobot", "livisi", @@ -300,7 +304,6 @@ FLOWS = { "mqtt", "mullvad", "mutesync", - "myq", "mysensors", "mystrom", "nam", @@ -344,6 +347,7 @@ FLOWS = { "opower", "oralb", "otbr", + "ourgroceries", "overkiz", "ovo_energy", "owntracks", @@ -351,9 +355,11 @@ FLOWS = { "panasonic_viera", "peco", "pegel_online", + "permobil", "philips_js", "pi_hole", "picnic", + "ping", "plaato", "plex", "plugwise", @@ -513,6 +519,7 @@ FLOWS = { "upnp", "uptime", "uptimerobot", + "v2c", "vallox", "velbus", "venstar", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index bc73c1b9804..6d04d7602f2 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -109,6 +109,10 @@ DHCP: list[dict[str, str | bool]] = [ "domain": "broadlink", "macaddress": "EC0BAE*", }, + { + "domain": "broadlink", + "macaddress": "780F77*", + }, { "domain": "dlink", "hostname": "dsp-w215", @@ -316,10 +320,6 @@ DHCP: list[dict[str, str | bool]] = [ "domain": "motion_blinds", "hostname": "connector_*", }, - { - "domain": "myq", - "macaddress": "645299*", - }, { "domain": "nest", "macaddress": "18B430*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f834f71bb07..2c6d8277309 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -765,7 +765,7 @@ "caldav": { "name": "CalDAV", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "canary": { @@ -1067,6 +1067,12 @@ } } }, + "devialet": { + "name": "Devialet", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "device_sun_light_trigger": { "name": "Presence-based Lights", "integration_type": "hub", @@ -1270,7 +1276,7 @@ }, "dwd_weather_warnings": { "name": "Deutscher Wetterdienst (DWD) Weather Warnings", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -1552,12 +1558,6 @@ "eq3": { "name": "eQ-3", "integrations": { - "eq3btsmart": { - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling", - "name": "eQ-3 Bluetooth Smart Thermostats" - }, "maxcube": { "integration_type": "hub", "config_flow": false, @@ -1656,7 +1656,7 @@ "fastdotcom": { "name": "Fast.com", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "feedreader": { @@ -1718,7 +1718,7 @@ }, "fints": { "name": "FinTS", - "integration_type": "hub", + "integration_type": "service", "config_flow": false, "iot_class": "cloud_polling" }, @@ -3047,6 +3047,12 @@ "config_flow": false, "iot_class": "assumed_state" }, + "linear_garage_door": { + "name": "Linear Garage Door", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "linksys_smart": { "name": "Linksys Smart Wi-Fi", "integration_type": "hub", @@ -3625,12 +3631,6 @@ "config_flow": false, "iot_class": "local_push" }, - "myq": { - "name": "MyQ", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "mysensors": { "name": "MySensors", "integration_type": "hub", @@ -4146,11 +4146,17 @@ "config_flow": false, "iot_class": "local_polling" }, + "ourgroceries": { + "name": "OurGroceries", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "overkiz": { "name": "Overkiz", "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling" + "iot_class": "local_polling" }, "ovo_energy": { "name": "OVO Energy", @@ -4236,6 +4242,12 @@ "integration_type": "virtual", "supported_by": "opower" }, + "permobil": { + "name": "MyPermobil", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "pge": { "name": "Pacific Gas & Electric (PG&E)", "integration_type": "virtual", @@ -4291,7 +4303,7 @@ "ping": { "name": "Ping (ICMP)", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "pioneer": { @@ -6098,7 +6110,7 @@ "iot_class": "cloud_polling" }, "universal": { - "name": "Universal Media Player", + "name": "Universal media player", "integration_type": "hub", "config_flow": false, "iot_class": "calculated" @@ -6155,6 +6167,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "v2c": { + "name": "V2C", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "vallox": { "name": "Vallox", "integration_type": "hub", diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index f58936caf8d..2fdd032c2dd 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -81,6 +81,12 @@ USB = [ "pid": "0030", "vid": "1CF1", }, + { + "description": "*conbee*", + "domain": "zha", + "pid": "6015", + "vid": "0403", + }, { "description": "*zigbee*", "domain": "zha", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 36ddfd68479..55570078d80 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -20,10 +20,6 @@ HOMEKIT = { "always_discover": True, "domain": "roku", }, - "819LMB": { - "always_discover": True, - "domain": "myq", - }, "AC02": { "always_discover": True, "domain": "tado", @@ -56,7 +52,7 @@ HOMEKIT = { "always_discover": True, "domain": "hive", }, - "Healty Home Coach": { + "Healthy Home Coach": { "always_discover": True, "domain": "netatmo", }, @@ -120,6 +116,10 @@ HOMEKIT = { "always_discover": True, "domain": "lifx", }, + "LIFX Neon": { + "always_discover": True, + "domain": "lifx", + }, "LIFX Nightvision": { "always_discover": True, "domain": "lifx", @@ -132,6 +132,10 @@ HOMEKIT = { "always_discover": True, "domain": "lifx", }, + "LIFX String": { + "always_discover": True, + "domain": "lifx", + }, "LIFX Tile": { "always_discover": True, "domain": "lifx", @@ -144,10 +148,6 @@ HOMEKIT = { "always_discover": True, "domain": "lifx", }, - "MYQ": { - "always_discover": True, - "domain": "myq", - }, "NL29": { "always_discover": False, "domain": "nanoleaf", @@ -364,6 +364,11 @@ ZEROCONF = { "domain": "forked_daapd", }, ], + "_devialet-http._tcp.local.": [ + { + "domain": "devialet", + }, + ], "_dkapi._tcp.local.": [ { "domain": "daikin", @@ -516,6 +521,12 @@ ZEROCONF = { "name": "gateway*", }, ], + "_kizboxdev._tcp.local.": [ + { + "domain": "overkiz", + "name": "gateway*", + }, + ], "_lookin._tcp.local.": [ { "domain": "lookin", @@ -704,6 +715,11 @@ ZEROCONF = { "domain": "wled", }, ], + "_wyoming._tcp.local.": [ + { + "domain": "wyoming", + }, + ], "_xbmc-jsonrpc-h._tcp.local.": [ { "domain": "kodi", diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index b8d810d899b..74527a5922f 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -58,19 +58,6 @@ MAXIMUM_CONNECTIONS = 4096 MAXIMUM_CONNECTIONS_PER_HOST = 100 -# Overwrite base aiohttp _wait implementation -# Homeassistant has a custom shutdown wait logic. -async def _noop_wait(*args: Any, **kwargs: Any) -> None: - """Do nothing.""" - return - - -# TODO: Remove version check with aiohttp 3.9.0 # pylint: disable=fixme -if sys.version_info >= (3, 12): - # pylint: disable-next=protected-access - web.BaseSite._wait = _noop_wait # type: ignore[method-assign] - - class HassClientResponse(aiohttp.ClientResponse): """aiohttp.ClientResponse with a json method that uses json_loads by default.""" @@ -311,7 +298,7 @@ def _async_get_connector( return connectors[connector_key] if verify_ssl: - ssl_context: bool | SSLContext = ssl_util.get_default_context() + ssl_context: SSLContext = ssl_util.get_default_context() else: ssl_context = ssl_util.get_default_no_verify_context() diff --git a/homeassistant/helpers/aiohttp_compat.py b/homeassistant/helpers/aiohttp_compat.py deleted file mode 100644 index 78aad44fa66..00000000000 --- a/homeassistant/helpers/aiohttp_compat.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Helper to restore old aiohttp behavior.""" -from __future__ import annotations - -from aiohttp import web_protocol, web_server - - -class CancelOnDisconnectRequestHandler(web_protocol.RequestHandler): - """Request handler that cancels tasks on disconnect.""" - - def connection_lost(self, exc: BaseException | None) -> None: - """Handle connection lost.""" - task_handler = self._task_handler - super().connection_lost(exc) - if task_handler is not None: - task_handler.cancel("aiohttp connection lost") - - -def restore_original_aiohttp_cancel_behavior() -> None: - """Patch aiohttp to restore cancel behavior. - - Remove this once aiohttp 3.9 is released as we can use - https://github.com/aio-libs/aiohttp/pull/7128 - """ - web_protocol.RequestHandler = CancelOnDisconnectRequestHandler # type: ignore[misc] - web_server.RequestHandler = CancelOnDisconnectRequestHandler # type: ignore[misc] diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index a5e68cb877d..23707949dcd 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -15,9 +15,10 @@ from homeassistant.config import ( # type: ignore[attr-defined] CONF_PACKAGES, CORE_CONFIG_SCHEMA, YAML_CONFIG_FILE, - _format_config_error, config_per_platform, extract_domain_configs, + format_homeassistant_error, + format_schema_error, load_yaml_config_file, merge_packages_config, ) @@ -48,6 +49,7 @@ class HomeAssistantConfig(OrderedDict): """Initialize HA config.""" super().__init__() self.errors: list[CheckConfigError] = [] + self.warnings: list[CheckConfigError] = [] def add_error( self, @@ -55,15 +57,30 @@ class HomeAssistantConfig(OrderedDict): domain: str | None = None, config: ConfigType | None = None, ) -> Self: - """Add a single error.""" + """Add an error.""" self.errors.append(CheckConfigError(str(message), domain, config)) return self @property def error_str(self) -> str: - """Return errors as a string.""" + """Concatenate all errors to a string.""" return "\n".join([err.message for err in self.errors]) + def add_warning( + self, + message: str, + domain: str | None = None, + config: ConfigType | None = None, + ) -> Self: + """Add a warning.""" + self.warnings.append(CheckConfigError(str(message), domain, config)) + return self + + @property + def warning_str(self) -> str: + """Concatenate all warnings to a string.""" + return "\n".join([err.message for err in self.warnings]) + async def async_check_ha_config_file( # noqa: C901 hass: HomeAssistant, @@ -76,17 +93,51 @@ async def async_check_ha_config_file( # noqa: C901 async_clear_install_history(hass) def _pack_error( - package: str, component: str, config: ConfigType, message: str + hass: HomeAssistant, + package: str, + component: str, + config: ConfigType, + message: str, ) -> None: - """Handle errors from packages: _log_pkg_error.""" - message = f"Package {package} setup failed. Component {component} {message}" + """Handle errors from packages.""" + message = f"Setup of package '{package}' failed: {message}" domain = f"homeassistant.packages.{package}.{component}" pack_config = core_config[CONF_PACKAGES].get(package, config) - result.add_error(message, domain, pack_config) + result.add_warning(message, domain, pack_config) - def _comp_error(ex: Exception, domain: str, config: ConfigType) -> None: - """Handle errors from components: async_log_exception.""" - result.add_error(_format_config_error(ex, domain, config)[0], domain, config) + def _comp_error( + ex: vol.Invalid | HomeAssistantError, + domain: str, + component_config: ConfigType, + config_to_attach: ConfigType, + ) -> None: + """Handle errors from components.""" + if isinstance(ex, vol.Invalid): + message = format_schema_error(hass, ex, domain, component_config) + else: + message = format_homeassistant_error(hass, ex, domain, component_config) + if domain in frontend_dependencies: + result.add_error(message, domain, config_to_attach) + else: + result.add_warning(message, domain, config_to_attach) + + async def _get_integration( + hass: HomeAssistant, domain: str + ) -> loader.Integration | None: + """Get an integration.""" + integration: loader.Integration | None = None + try: + integration = await async_get_integration_with_requirements(hass, domain) + except loader.IntegrationNotFound as ex: + # We get this error if an integration is not found. In recovery mode and + # safe mode, this currently happens for all custom integrations. Don't + # show errors for a missing integration in recovery mode or safe mode to + # not confuse the user. + if not hass.config.recovery_mode and not hass.config.safe_mode: + result.add_warning(f"Integration error: {domain} - {ex}") + except RequirementsNotFound as ex: + result.add_warning(f"Integration error: {domain} - {ex}") + return integration # Load configuration.yaml config_path = hass.config.path(YAML_CONFIG_FILE) @@ -110,7 +161,11 @@ async def async_check_ha_config_file( # noqa: C901 core_config = CORE_CONFIG_SCHEMA(core_config) result[CONF_CORE] = core_config except vol.Invalid as err: - result.add_error(err, CONF_CORE, core_config) + result.add_error( + format_schema_error(hass, err, CONF_CORE, core_config), + CONF_CORE, + core_config, + ) core_config = {} # Merge packages @@ -122,22 +177,22 @@ async def async_check_ha_config_file( # noqa: C901 # Filter out repeating config sections components = {key.partition(" ")[0] for key in config} + frontend_dependencies: set[str] = set() + if "frontend" in components or "default_config" in components: + frontend = await _get_integration(hass, "frontend") + if frontend: + await frontend.resolve_dependencies() + frontend_dependencies = frontend.all_dependencies | {"frontend"} + # Process and validate config for domain in components: - try: - integration = await async_get_integration_with_requirements(hass, domain) - except loader.IntegrationNotFound as ex: - if not hass.config.recovery_mode and not hass.config.safe_mode: - result.add_error(f"Integration error: {domain} - {ex}") - continue - except RequirementsNotFound as ex: - result.add_error(f"Integration error: {domain} - {ex}") + if not (integration := await _get_integration(hass, domain)): continue try: component = integration.get_component() except ImportError as ex: - result.add_error(f"Component error: {domain} - {ex}") + result.add_warning(f"Component error: {domain} - {ex}") continue # Check if the integration has a custom config validator @@ -161,7 +216,7 @@ async def async_check_ha_config_file( # noqa: C901 )[domain] continue except (vol.Invalid, HomeAssistantError) as ex: - _comp_error(ex, domain, config) + _comp_error(ex, domain, config, config[domain]) continue except Exception as err: # pylint: disable=broad-except logging.getLogger(__name__).exception( @@ -177,12 +232,12 @@ async def async_check_ha_config_file( # noqa: C901 config_schema = getattr(component, "CONFIG_SCHEMA", None) if config_schema is not None: try: - config = config_schema(config) + validated_config = config_schema(config) # Don't fail if the validator removed the domain from the config - if domain in config: - result[domain] = config[domain] + if domain in validated_config: + result[domain] = validated_config[domain] except vol.Invalid as ex: - _comp_error(ex, domain, config) + _comp_error(ex, domain, config, config[domain]) continue component_platform_schema = getattr( @@ -200,7 +255,7 @@ async def async_check_ha_config_file( # noqa: C901 try: p_validated = component_platform_schema(p_config) except vol.Invalid as ex: - _comp_error(ex, domain, config) + _comp_error(ex, domain, p_config, p_config) continue # Not all platform components follow same pattern for platforms @@ -216,14 +271,18 @@ async def async_check_ha_config_file( # noqa: C901 ) platform = p_integration.get_platform(domain) except loader.IntegrationNotFound as ex: + # We get this error if an integration is not found. In recovery mode and + # safe mode, this currently happens for all custom integrations. Don't + # show errors for a missing integration in recovery mode or safe mode to + # not confuse the user. if not hass.config.recovery_mode and not hass.config.safe_mode: - result.add_error(f"Platform error {domain}.{p_name} - {ex}") + result.add_warning(f"Platform error {domain}.{p_name} - {ex}") continue except ( RequirementsNotFound, ImportError, ) as ex: - result.add_error(f"Platform error {domain}.{p_name} - {ex}") + result.add_warning(f"Platform error {domain}.{p_name} - {ex}") continue # Validate platform specific schema @@ -232,7 +291,7 @@ async def async_check_ha_config_file( # noqa: C901 try: p_validated = platform_schema(p_validated) except vol.Invalid as ex: - _comp_error(ex, f"{domain}.{p_name}", p_validated) + _comp_error(ex, f"{domain}.{p_name}", p_config, p_config) continue platforms.append(p_validated) diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 1a106364566..5b4b803a8d4 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -10,12 +10,14 @@ from __future__ import annotations from abc import ABC, ABCMeta, abstractmethod import asyncio from collections.abc import Awaitable, Callable +from http import HTTPStatus +from json import JSONDecodeError import logging import secrets import time from typing import Any, cast -from aiohttp import client, web +from aiohttp import ClientError, ClientResponseError, client, web import jwt import voluptuous as vol from yarl import URL @@ -199,12 +201,15 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): _LOGGER.debug("Sending token request to %s", self.token_url) resp = await session.post(self.token_url, data=data) - if resp.status >= 400 and _LOGGER.isEnabledFor(logging.DEBUG): - body = await resp.text() - _LOGGER.debug( - "Token request failed with status=%s, body=%s", - resp.status, - body, + if resp.status >= 400: + try: + error_response = await resp.json() + except (ClientError, JSONDecodeError): + error_response = {} + error_code = error_response.get("error", "unknown") + error_description = error_response.get("error_description", "unknown error") + _LOGGER.error( + "Token request failed (%s): %s", error_code, error_description ) resp.raise_for_status() return cast(dict, await resp.json()) @@ -317,7 +322,14 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): ) except asyncio.TimeoutError as err: _LOGGER.error("Timeout resolving OAuth token: %s", err) - return self.async_abort(reason="oauth2_timeout") + return self.async_abort(reason="oauth_timeout") + except (ClientResponseError, ClientError) as err: + if ( + isinstance(err, ClientResponseError) + and err.status == HTTPStatus.UNAUTHORIZED + ): + return self.async_abort(reason="oauth_unauthorized") + return self.async_abort(reason="oauth_failed") if "expires_in" not in token: _LOGGER.warning("Invalid token: %s", token) diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index c499dd0b6cd..5a0682fdda2 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -99,7 +99,11 @@ def get_deprecated( def deprecated_class( replacement: str, ) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: - """Mark class as deprecated and provide a replacement class to be used instead.""" + """Mark class as deprecated and provide a replacement class to be used instead. + + If the deprecated function was called from a custom integration, ask the user to + report an issue. + """ def deprecated_decorator(cls: Callable[_P, _R]) -> Callable[_P, _R]: """Decorate class as deprecated.""" @@ -107,7 +111,7 @@ def deprecated_class( @functools.wraps(cls) def deprecated_cls(*args: _P.args, **kwargs: _P.kwargs) -> _R: """Wrap for the original class.""" - _print_deprecation_warning(cls, replacement, "class") + _print_deprecation_warning(cls, replacement, "class", "instantiated") return cls(*args, **kwargs) return deprecated_cls @@ -118,7 +122,11 @@ def deprecated_class( def deprecated_function( replacement: str, ) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: - """Mark function as deprecated and provide a replacement to be used instead.""" + """Mark function as deprecated and provide a replacement to be used instead. + + If the deprecated function was called from a custom integration, ask the user to + report an issue. + """ def deprecated_decorator(func: Callable[_P, _R]) -> Callable[_P, _R]: """Decorate function as deprecated.""" @@ -126,7 +134,7 @@ def deprecated_function( @functools.wraps(func) def deprecated_func(*args: _P.args, **kwargs: _P.kwargs) -> _R: """Wrap for the original function.""" - _print_deprecation_warning(func, replacement, "function") + _print_deprecation_warning(func, replacement, "function", "called") return func(*args, **kwargs) return deprecated_func @@ -134,10 +142,23 @@ def deprecated_function( return deprecated_decorator -def _print_deprecation_warning(obj: Any, replacement: str, description: str) -> None: +def _print_deprecation_warning( + obj: Any, + replacement: str, + description: str, + verb: str, +) -> None: logger = logging.getLogger(obj.__module__) try: integration_frame = get_integration_frame() + except MissingIntegrationFrame: + logger.warning( + "%s is a deprecated %s. Use %s instead", + obj.__name__, + description, + replacement, + ) + else: if integration_frame.custom_integration: hass: HomeAssistant | None = None with suppress(HomeAssistantError): @@ -149,10 +170,11 @@ def _print_deprecation_warning(obj: Any, replacement: str, description: str) -> ) logger.warning( ( - "%s was called from %s, this is a deprecated %s. Use %s instead," + "%s was %s from %s, this is a deprecated %s. Use %s instead," " please %s" ), obj.__name__, + verb, integration_frame.integration, description, replacement, @@ -160,16 +182,10 @@ def _print_deprecation_warning(obj: Any, replacement: str, description: str) -> ) else: logger.warning( - "%s was called from %s, this is a deprecated %s. Use %s instead", + "%s was %s from %s, this is a deprecated %s. Use %s instead", obj.__name__, + verb, integration_frame.integration, description, replacement, ) - except MissingIntegrationFrame: - logger.warning( - "%s is a deprecated %s. Use %s instead", - obj.__name__, - description, - replacement, - ) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 48ebd7b6ebc..9a26821faaf 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -823,15 +823,8 @@ class DeviceRegistry: for device in data["deleted_devices"]: deleted_devices[device["id"]] = DeletedDeviceEntry( config_entries=set(device["config_entries"]), - # type ignores (if tuple arg was cast): likely https://github.com/python/mypy/issues/8625 - connections={ - tuple(conn) # type: ignore[misc] - for conn in device["connections"] - }, - identifiers={ - tuple(iden) # type: ignore[misc] - for iden in device["identifiers"] - }, + connections={tuple(conn) for conn in device["connections"]}, + identifiers={tuple(iden) for iden in device["identifiers"]}, id=device["id"], orphaned_timestamp=device["orphaned_timestamp"], ) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 1bc8f0b308b..7877ca0e613 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -311,6 +311,8 @@ class Entity(ABC): # and removes the need for constant None checks or asserts. _state_info: StateInfo = None # type: ignore[assignment] + __remove_event: asyncio.Event | None = None + # Entity Properties _attr_assumed_state: bool = False _attr_attribution: str | None = None @@ -987,9 +989,9 @@ class Entity(ABC): parallel_updates: asyncio.Semaphore | None, ) -> None: """Start adding an entity to a platform.""" - if self._platform_state == EntityPlatformState.ADDED: + if self._platform_state != EntityPlatformState.NOT_ADDED: raise HomeAssistantError( - f"Entity {self.entity_id} cannot be added a second time to an entity" + f"Entity '{self.entity_id}' cannot be added a second time to an entity" " platform" ) @@ -1009,7 +1011,7 @@ class Entity(ABC): def add_to_platform_abort(self) -> None: """Abort adding an entity to a platform.""" - self._platform_state = EntityPlatformState.NOT_ADDED + self._platform_state = EntityPlatformState.REMOVED self._call_on_remove_callbacks() self.hass = None # type: ignore[assignment] @@ -1022,6 +1024,7 @@ class Entity(ABC): await self.async_added_to_hass() self.async_write_ha_state() + @final async def async_remove(self, *, force_remove: bool = False) -> None: """Remove entity from Home Assistant. @@ -1032,12 +1035,19 @@ class Entity(ABC): If the entity doesn't have a non disabled entry in the entity registry, or if force_remove=True, its state will be removed. """ - # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 - if self.platform and self._platform_state != EntityPlatformState.ADDED: - raise HomeAssistantError( - f"Entity {self.entity_id} async_remove called twice" - ) + if self.__remove_event is not None: + await self.__remove_event.wait() + return + + self.__remove_event = asyncio.Event() + try: + await self.__async_remove_impl(force_remove) + finally: + self.__remove_event.set() + + @final + async def __async_remove_impl(self, force_remove: bool) -> None: + """Remove entity from Home Assistant.""" self._platform_state = EntityPlatformState.REMOVED @@ -1099,7 +1109,7 @@ class Entity(ABC): # This is an assert as it should never happen, but helps in tests assert ( not self.registry_entry.disabled_by - ), f"Entity {self.entity_id} is being added while it's disabled" + ), f"Entity '{self.entity_id}' is being added while it's disabled" self.async_on_remove( async_track_entity_registry_updated_event( @@ -1156,6 +1166,10 @@ class Entity(ABC): await self.async_remove(force_remove=True) self.entity_id = registry_entry.entity_id + + # Clear the remove event to handle entity added again after entity id change + self.__remove_event = None + self._platform_state = EntityPlatformState.NOT_ADDED await self.platform.async_add_entities([self]) @callback diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index af1b87ec0fa..775d0934c36 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -20,6 +20,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import ( + EntityServiceResponse, Event, HomeAssistant, ServiceCall, @@ -217,6 +218,40 @@ class EntityComponent(Generic[_EntityT]): self.hass, self.entities, service_call, expand_group ) + @callback + def async_register_legacy_entity_service( + self, + name: str, + schema: dict[str | vol.Marker, Any] | vol.Schema, + func: str | Callable[..., Any], + required_features: list[int] | None = None, + supports_response: SupportsResponse = SupportsResponse.NONE, + ) -> None: + """Register an entity service with a legacy response format.""" + if isinstance(schema, dict): + schema = cv.make_entity_service_schema(schema) + + async def handle_service( + call: ServiceCall, + ) -> ServiceResponse: + """Handle the service.""" + + result = await service.entity_service_call( + self.hass, self._platforms.values(), func, call, required_features + ) + + if result: + if len(result) > 1: + raise HomeAssistantError( + "Deprecated service call matched more than one entity" + ) + return result.popitem()[1] + return None + + self.hass.services.async_register( + self.domain, name, handle_service, schema, supports_response + ) + @callback def async_register_entity_service( self, @@ -230,7 +265,9 @@ class EntityComponent(Generic[_EntityT]): if isinstance(schema, dict): schema = cv.make_entity_service_schema(schema) - async def handle_service(call: ServiceCall) -> ServiceResponse: + async def handle_service( + call: ServiceCall, + ) -> EntityServiceResponse | None: """Handle the service.""" return await service.entity_service_call( self.hass, self._platforms.values(), func, call, required_features @@ -318,7 +355,7 @@ class EntityComponent(Generic[_EntityT]): integration = await async_get_integration(self.hass, self.domain) - processed_conf = await conf_util.async_process_component_config( + processed_conf = await conf_util.async_process_component_and_handle_errors( self.hass, conf, integration ) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index c164e3b1052..2fc82567739 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -20,8 +20,10 @@ from homeassistant.core import ( CALLBACK_TYPE, DOMAIN as HOMEASSISTANT_DOMAIN, CoreState, + EntityServiceResponse, HomeAssistant, ServiceCall, + SupportsResponse, callback, split_entity_id, valid_entity_id, @@ -811,9 +813,10 @@ class EntityPlatform: def async_register_entity_service( self, name: str, - schema: dict[str, Any] | vol.Schema, + schema: dict[str | vol.Marker, Any] | vol.Schema, func: str | Callable[..., Any], required_features: Iterable[int] | None = None, + supports_response: SupportsResponse = SupportsResponse.NONE, ) -> None: """Register an entity service. @@ -825,9 +828,9 @@ class EntityPlatform: if isinstance(schema, dict): schema = cv.make_entity_service_schema(schema) - async def handle_service(call: ServiceCall) -> None: + async def handle_service(call: ServiceCall) -> EntityServiceResponse | None: """Handle the service.""" - await service.entity_service_call( + return await service.entity_service_call( self.hass, [ plf @@ -840,7 +843,7 @@ class EntityPlatform: ) self.hass.services.async_register( - self.platform_name, name, handle_service, schema + self.platform_name, name, handle_service, schema, supports_response ) async def _update_entity_states(self, now: datetime) -> None: diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index a97e283af07..65ae1a8e9e5 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -131,7 +131,7 @@ EventEntityRegistryUpdatedData = ( EntityOptionsType = Mapping[str, Mapping[str, Any]] -ReadOnlyEntityOptionsType = ReadOnlyDict[str, Mapping[str, Any]] +ReadOnlyEntityOptionsType = ReadOnlyDict[str, ReadOnlyDict[str, Any]] DISLAY_DICT_OPTIONAL = ( ("ai", "area_id"), diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 2da8a48be98..1de7a6c6a43 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -24,6 +24,7 @@ from homeassistant.const import ( from homeassistant.core import ( CALLBACK_TYPE, HassJob, + HassJobType, HomeAssistant, State, callback, @@ -250,7 +251,9 @@ def async_track_state_change( return async_track_state_change_event(hass, entity_ids, state_change_listener) return hass.bus.async_listen( - EVENT_STATE_CHANGED, state_change_dispatcher, event_filter=state_change_filter # type: ignore[arg-type] + EVENT_STATE_CHANGED, + state_change_dispatcher, # type: ignore[arg-type] + event_filter=state_change_filter, # type: ignore[arg-type] ) @@ -394,8 +397,8 @@ def _async_track_event( if listeners_key not in hass_data: hass_data[listeners_key] = hass.bus.async_listen( event_type, - callback(ft.partial(dispatcher_callable, hass, callbacks)), - event_filter=callback(ft.partial(filter_callable, hass, callbacks)), + ft.partial(dispatcher_callable, hass, callbacks), + event_filter=ft.partial(filter_callable, hass, callbacks), ) job = HassJob(action, f"track {event_type} event {keys}") @@ -760,7 +763,8 @@ class _TrackStateChangeFiltered: @callback def _setup_all_listener(self) -> None: self._listeners[_ALL_LISTENER] = self.hass.bus.async_listen( - EVENT_STATE_CHANGED, self._action # type: ignore[arg-type] + EVENT_STATE_CHANGED, + self._action, # type: ignore[arg-type] ) @@ -1334,7 +1338,8 @@ def async_track_same_state( if entity_ids == MATCH_ALL: async_remove_state_for_cancel = hass.bus.async_listen( - EVENT_STATE_CHANGED, state_for_cancel_listener # type: ignore[arg-type] + EVENT_STATE_CHANGED, + state_for_cancel_listener, # type: ignore[arg-type] ) else: async_remove_state_for_cancel = async_track_state_change_event( @@ -1357,7 +1362,10 @@ def async_track_point_in_time( | Callable[[datetime], Coroutine[Any, Any, None] | None], point_in_time: datetime, ) -> CALLBACK_TYPE: - """Add a listener that fires once after a specific point in time.""" + """Add a listener that fires once at or after a specific point in time. + + The listener is passed the time it fires in local time. + """ job = ( action if isinstance(action, HassJob) @@ -1373,6 +1381,7 @@ def async_track_point_in_time( utc_converter, name=f"{job.name} UTC converter", cancel_on_shutdown=job.cancel_on_shutdown, + job_type=HassJobType.Callback, ) return async_track_point_in_utc_time(hass, track_job, point_in_time) @@ -1388,7 +1397,10 @@ def async_track_point_in_utc_time( | Callable[[datetime], Coroutine[Any, Any, None] | None], point_in_time: datetime, ) -> CALLBACK_TYPE: - """Add a listener that fires once after a specific point in UTC time.""" + """Add a listener that fires once at or after a specific point in time. + + The listener is passed the time it fires in UTC time. + """ # Ensure point_in_time is UTC utc_point_in_time = dt_util.as_utc(point_in_time) expected_fire_timestamp = dt_util.utc_to_timestamp(utc_point_in_time) @@ -1450,7 +1462,10 @@ def async_call_at( | Callable[[datetime], Coroutine[Any, Any, None] | None], loop_time: float, ) -> CALLBACK_TYPE: - """Add a listener that is called at .""" + """Add a listener that fires at or after . + + The listener is passed the time it fires in UTC time. + """ job = ( action if isinstance(action, HassJob) @@ -1467,7 +1482,10 @@ def async_call_later( action: HassJob[[datetime], Coroutine[Any, Any, None] | None] | Callable[[datetime], Coroutine[Any, Any, None] | None], ) -> CALLBACK_TYPE: - """Add a listener that is called in .""" + """Add a listener that fires at or after . + + The listener is passed the time it fires in UTC time. + """ if isinstance(delay, timedelta): delay = delay.total_seconds() job = ( @@ -1492,7 +1510,10 @@ def async_track_time_interval( name: str | None = None, cancel_on_shutdown: bool | None = None, ) -> CALLBACK_TYPE: - """Add a listener that fires repetitively at every timedelta interval.""" + """Add a listener that fires repetitively at every timedelta interval. + + The listener is passed the time it fires in UTC time. + """ remove: CALLBACK_TYPE interval_listener_job: HassJob[[datetime], None] interval_seconds = interval.total_seconds() @@ -1516,7 +1537,10 @@ def async_track_time_interval( job_name = f"track time interval {interval} {action}" interval_listener_job = HassJob( - interval_listener, job_name, cancel_on_shutdown=cancel_on_shutdown + interval_listener, + job_name, + cancel_on_shutdown=cancel_on_shutdown, + job_type=HassJobType.Callback, ) remove = async_call_later(hass, interval_seconds, interval_listener_job) @@ -1636,7 +1660,10 @@ def async_track_utc_time_change( second: Any | None = None, local: bool = False, ) -> CALLBACK_TYPE: - """Add a listener that will fire if time matches a pattern.""" + """Add a listener that will fire every time the UTC or local time matches a pattern. + + The listener is passed the time it fires in UTC or local time. + """ # We do not have to wrap the function with time pattern matching logic # if no pattern given if all(val is None or val == "*" for val in (hour, minute, second)): @@ -1685,6 +1712,7 @@ def async_track_utc_time_change( pattern_time_change_listener_job = HassJob( pattern_time_change_listener, f"time change listener {hour}:{minute}:{second} {action}", + job_type=HassJobType.Callback, ) time_listener = async_track_point_in_utc_time( hass, pattern_time_change_listener_job, calculate_next(dt_util.utcnow()) @@ -1711,7 +1739,10 @@ def async_track_time_change( minute: Any | None = None, second: Any | None = None, ) -> CALLBACK_TYPE: - """Add a listener that will fire if local time matches a pattern.""" + """Add a listener that will fire every time the local time matches a pattern. + + The listener is passed the time it fires in local time. + """ return async_track_utc_time_change(hass, action, hour, minute, second, local=True) diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py index 12accf2725a..58ca191feb0 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -299,3 +299,14 @@ def _get_cloud_url(hass: HomeAssistant, require_current_request: bool = False) - return normalize_url(str(cloud_url)) raise NoURLAvailableError + + +def is_cloud_connection(hass: HomeAssistant) -> bool: + """Return True if the current connection is a nabucasa cloud connection.""" + + if "cloud" not in hass.config.components: + return False + + from hass_nabucasa import remote # pylint: disable=import-outside-toplevel + + return remote.is_cloud_request.get() diff --git a/homeassistant/helpers/reload.py b/homeassistant/helpers/reload.py index 75529476dd2..42ebc2d0869 100644 --- a/homeassistant/helpers/reload.py +++ b/homeassistant/helpers/reload.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Iterable import logging -from typing import Any +from typing import Any, Literal, overload from homeassistant import config as conf_util from homeassistant.const import SERVICE_RELOAD @@ -26,7 +26,7 @@ PLATFORM_RESET_LOCK = "lock_async_reset_platform_{}" async def async_reload_integration_platforms( - hass: HomeAssistant, integration_name: str, integration_platforms: Iterable[str] + hass: HomeAssistant, integration_domain: str, platform_domains: Iterable[str] ) -> None: """Reload an integration's platforms. @@ -44,10 +44,8 @@ async def async_reload_integration_platforms( return tasks = [ - _resetup_platform( - hass, integration_name, integration_platform, unprocessed_conf - ) - for integration_platform in integration_platforms + _resetup_platform(hass, integration_domain, platform_domain, unprocessed_conf) + for platform_domain in platform_domains ] await asyncio.gather(*tasks) @@ -55,27 +53,27 @@ async def async_reload_integration_platforms( async def _resetup_platform( hass: HomeAssistant, - integration_name: str, - integration_platform: str, - unprocessed_conf: ConfigType, + integration_domain: str, + platform_domain: str, + unprocessed_config: ConfigType, ) -> None: """Resetup a platform.""" - integration = await async_get_integration(hass, integration_platform) + integration = await async_get_integration(hass, platform_domain) - conf = await conf_util.async_process_component_config( - hass, unprocessed_conf, integration + conf = await conf_util.async_process_component_and_handle_errors( + hass, unprocessed_config, integration ) if not conf: return - root_config: dict[str, list[ConfigType]] = {integration_platform: []} + root_config: dict[str, list[ConfigType]] = {platform_domain: []} # Extract only the config for template, ignore the rest. - for p_type, p_config in config_per_platform(conf, integration_platform): - if p_type != integration_name: + for p_type, p_config in config_per_platform(conf, platform_domain): + if p_type != integration_domain: continue - root_config[integration_platform].append(p_config) + root_config[platform_domain].append(p_config) component = integration.get_component() @@ -83,47 +81,47 @@ async def _resetup_platform( # If the integration has its own way to reset # use this method. async with hass.data.setdefault( - PLATFORM_RESET_LOCK.format(integration_platform), asyncio.Lock() + PLATFORM_RESET_LOCK.format(platform_domain), asyncio.Lock() ): - await component.async_reset_platform(hass, integration_name) + await component.async_reset_platform(hass, integration_domain) await component.async_setup(hass, root_config) return # If it's an entity platform, we use the entity_platform # async_reset method platform = async_get_platform_without_config_entry( - hass, integration_name, integration_platform + hass, integration_domain, platform_domain ) if platform: - await _async_reconfig_platform(platform, root_config[integration_platform]) + await _async_reconfig_platform(platform, root_config[platform_domain]) return - if not root_config[integration_platform]: + if not root_config[platform_domain]: # No config for this platform # and it's not loaded. Nothing to do. return await _async_setup_platform( - hass, integration_name, integration_platform, root_config[integration_platform] + hass, integration_domain, platform_domain, root_config[platform_domain] ) async def _async_setup_platform( hass: HomeAssistant, - integration_name: str, - integration_platform: str, + integration_domain: str, + platform_domain: str, platform_configs: list[dict[str, Any]], ) -> None: """Platform for the first time when new configuration is added.""" - if integration_platform not in hass.data: + if platform_domain not in hass.data: await async_setup_component( - hass, integration_platform, {integration_platform: platform_configs} + hass, platform_domain, {platform_domain: platform_configs} ) return - entity_component: EntityComponent[Entity] = hass.data[integration_platform] + entity_component: EntityComponent[Entity] = hass.data[platform_domain] tasks = [ - entity_component.async_setup_platform(integration_name, p_config) + entity_component.async_setup_platform(integration_domain, p_config) for p_config in platform_configs ] await asyncio.gather(*tasks) @@ -138,14 +136,41 @@ async def _async_reconfig_platform( await asyncio.gather(*tasks) +@overload async def async_integration_yaml_config( hass: HomeAssistant, integration_name: str +) -> ConfigType | None: + ... + + +@overload +async def async_integration_yaml_config( + hass: HomeAssistant, + integration_name: str, + *, + raise_on_failure: Literal[True], +) -> ConfigType: + ... + + +@overload +async def async_integration_yaml_config( + hass: HomeAssistant, + integration_name: str, + *, + raise_on_failure: Literal[False] | bool, +) -> ConfigType | None: + ... + + +async def async_integration_yaml_config( + hass: HomeAssistant, integration_name: str, *, raise_on_failure: bool = False ) -> ConfigType | None: """Fetch the latest yaml configuration for an integration.""" integration = await async_get_integration(hass, integration_name) - - return await conf_util.async_process_component_config( - hass, await conf_util.async_hass_config_yaml(hass), integration + config = await conf_util.async_hass_config_yaml(hass) + return await conf_util.async_process_component_and_handle_errors( + hass, config, integration, raise_on_failure=raise_on_failure ) diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 4dd71a584ec..625bab8b218 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -190,7 +190,8 @@ class RestoreStateData: state, self.entities[state.entity_id].extra_restore_state_data, now ) for state in all_states - if state.entity_id in self.entities and + if state.entity_id in self.entities + and # Ignore all states that are entity registry placeholders not state.attributes.get(ATTR_RESTORED) ] diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 51a54b3988f..f7ceb4ab812 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -425,10 +425,20 @@ class ColorRGBSelector(Selector[ColorRGBSelectorConfig]): class ColorTempSelectorConfig(TypedDict, total=False): """Class to represent a color temp selector config.""" + unit: ColorTempSelectorUnit + min: int + max: int max_mireds: int min_mireds: int +class ColorTempSelectorUnit(StrEnum): + """Possible units for a color temperature selector.""" + + KELVIN = "kelvin" + MIRED = "mired" + + @SELECTORS.register("color_temp") class ColorTempSelector(Selector[ColorTempSelectorConfig]): """Selector of an color temperature.""" @@ -437,6 +447,11 @@ class ColorTempSelector(Selector[ColorTempSelectorConfig]): CONFIG_SCHEMA = vol.Schema( { + vol.Optional("unit", default=ColorTempSelectorUnit.MIRED): vol.All( + vol.Coerce(ColorTempSelectorUnit), lambda val: val.value + ), + vol.Optional("min"): vol.Coerce(int), + vol.Optional("max"): vol.Coerce(int), vol.Optional("max_mireds"): vol.Coerce(int), vol.Optional("min_mireds"): vol.Coerce(int), } @@ -448,18 +463,27 @@ class ColorTempSelector(Selector[ColorTempSelectorConfig]): def __call__(self, data: Any) -> int: """Validate the passed selection.""" + range_min = self.config.get("min") + range_max = self.config.get("max") + + if not range_min: + range_min = self.config.get("min_mireds") + + if not range_max: + range_max = self.config.get("max_mireds") + value: int = vol.All( vol.Coerce(float), vol.Range( - min=self.config.get("min_mireds"), - max=self.config.get("max_mireds"), + min=range_min, + max=range_max, ), )(data) return value class ConditionSelectorConfig(TypedDict): - """Class to represent an action selector config.""" + """Class to represent an condition selector config.""" @SELECTORS.register("condition") @@ -1182,6 +1206,7 @@ class TextSelectorConfig(TypedDict, total=False): suffix: str type: TextSelectorType autocomplete: str + multiple: bool class TextSelectorType(StrEnum): @@ -1219,6 +1244,7 @@ class TextSelector(Selector[TextSelectorConfig]): vol.Coerce(TextSelectorType), lambda val: val.value ), vol.Optional("autocomplete"): str, + vol.Optional("multiple", default=False): bool, } ) @@ -1226,10 +1252,14 @@ class TextSelector(Selector[TextSelectorConfig]): """Instantiate a selector.""" super().__init__(config) - def __call__(self, data: Any) -> str: + def __call__(self, data: Any) -> str | list[str]: """Validate the passed selection.""" - text: str = vol.Schema(str)(data) - return text + if not self.config["multiple"]: + text: str = vol.Schema(str)(data) + return text + if not isinstance(data, list): + raise vol.Invalid("Value should be a list") + return [vol.Schema(str)(val) for val in data] class ThemeSelectorConfig(TypedDict): @@ -1280,6 +1310,27 @@ class TimeSelector(Selector[TimeSelectorConfig]): return cast(str, data) +class TriggerSelectorConfig(TypedDict): + """Class to represent an trigger selector config.""" + + +@SELECTORS.register("trigger") +class TriggerSelector(Selector[TriggerSelectorConfig]): + """Selector of a trigger sequence (script syntax).""" + + selector_type = "trigger" + + CONFIG_SCHEMA = vol.Schema({}) + + def __init__(self, config: TriggerSelectorConfig | None = None) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> Any: + """Validate the passed selection.""" + return vol.Schema(cv.TRIGGER_SCHEMA)(data) + + class FileSelectorConfig(TypedDict): """Class to represent a file selector config.""" diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 4532e1a00ae..32f51a924f7 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -28,6 +28,7 @@ from homeassistant.const import ( ) from homeassistant.core import ( Context, + EntityServiceResponse, HomeAssistant, ServiceCall, ServiceResponse, @@ -548,9 +549,11 @@ def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_T "Unable to find services.yaml for the %s integration", integration.domain ) return {} - except (HomeAssistantError, vol.Invalid): + except (HomeAssistantError, vol.Invalid) as ex: _LOGGER.warning( - "Unable to parse services.yaml for the %s integration", integration.domain + "Unable to parse services.yaml for the %s integration: %s", + integration.domain, + ex, ) return {} @@ -790,7 +793,7 @@ async def entity_service_call( func: str | Callable[..., Coroutine[Any, Any, ServiceResponse]], call: ServiceCall, required_features: Iterable[int] | None = None, -) -> ServiceResponse | None: +) -> EntityServiceResponse | None: """Handle an entity service call. Calls all platforms simultaneously. @@ -870,10 +873,9 @@ async def entity_service_call( return None if len(entities) == 1: - # Single entity case avoids creating tasks and allows returning - # ServiceResponse + # Single entity case avoids creating task entity = entities[0] - response_data = await _handle_entity_call( + single_response = await _handle_entity_call( hass, entity, func, data, call.context ) if entity.should_poll: @@ -881,27 +883,25 @@ async def entity_service_call( # Set context again so it's there when we update entity.async_set_context(call.context) await entity.async_update_ha_state(True) - return response_data if return_response else None + return {entity.entity_id: single_response} if return_response else None - if return_response: - raise HomeAssistantError( - "Service call requested response data but matched more than one entity" - ) - - done, pending = await asyncio.wait( - [ - asyncio.create_task( - entity.async_request_call( - _handle_entity_call(hass, entity, func, data, call.context) - ) + # Use asyncio.gather here to ensure the returned results + # are in the same order as the entities list + results: list[ServiceResponse | BaseException] = await asyncio.gather( + *[ + entity.async_request_call( + _handle_entity_call(hass, entity, func, data, call.context) ) for entity in entities - ] + ], + return_exceptions=True, ) - assert not pending - for task in done: - task.result() # pop exception if have + response_data: EntityServiceResponse = {} + for entity, result in zip(entities, results): + if isinstance(result, BaseException): + raise result from None + response_data[entity.entity_id] = result tasks: list[asyncio.Task[None]] = [] @@ -920,7 +920,7 @@ async def entity_service_call( for future in done: future.result() # pop exception if have - return None + return response_data if return_response and response_data else None async def _handle_entity_call( @@ -943,7 +943,7 @@ async def _handle_entity_call( # Guard because callback functions do not return a task when passed to # async_run_job. - result: ServiceResponse | None = None + result: ServiceResponse = None if task is not None: result = await task diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 06280a26ccd..721ac8bd5be 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -6,7 +6,7 @@ import asyncio import base64 import collections.abc from collections.abc import Callable, Collection, Generator, Iterable, MutableMapping -from contextlib import contextmanager, suppress +from contextlib import AbstractContextManager, suppress from contextvars import ContextVar from datetime import datetime, timedelta from functools import cache, lru_cache, partial, wraps @@ -20,7 +20,7 @@ import re import statistics from struct import error as StructError, pack, unpack_from import sys -from types import CodeType +from types import CodeType, TracebackType from typing import ( Any, Concatenate, @@ -504,7 +504,8 @@ class Template: def ensure_valid(self) -> None: """Return if template is valid.""" - with set_template(self.template, "compiling"): + with _template_context_manager as cm: + cm.set_template(self.template, "compiling") if self.is_static or self._compiled_code is not None: return @@ -2213,21 +2214,32 @@ def iif( return if_false -@contextmanager -def set_template(template_str: str, action: str) -> Generator: - """Store template being parsed or rendered in a Contextvar to aid error handling.""" - template_cv.set((template_str, action)) - try: - yield - finally: +class TemplateContextManager(AbstractContextManager): + """Context manager to store template being parsed or rendered in a ContextVar.""" + + def set_template(self, template_str: str, action: str) -> None: + """Store template being parsed or rendered in a Contextvar to aid error handling.""" + template_cv.set((template_str, action)) + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """Raise any exception triggered within the runtime context.""" template_cv.set(None) +_template_context_manager = TemplateContextManager() + + def _render_with_context( template_str: str, template: jinja2.Template, **kwargs: Any ) -> str: """Store template being rendered in a ContextVar to aid error handling.""" - with set_template(template_str, "rendering"): + with _template_context_manager as cm: + cm.set_template(template_str, "rendering") return template.render(**kwargs) @@ -2555,7 +2567,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["expand"] = hassfunction(expand) self.filters["expand"] = self.globals["expand"] self.globals["closest"] = hassfunction(closest) - self.filters["closest"] = hassfunction(closest_filter) # type: ignore[arg-type] + self.filters["closest"] = hassfunction(closest_filter) self.globals["distance"] = hassfunction(distance) self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) self.tests["is_hidden_entity"] = hassfunction( @@ -2596,7 +2608,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): return super().is_safe_attribute(obj, attr, value) @overload - def compile( # type: ignore[misc] + def compile( # type: ignore[overload-overlap] self, source: str | jinja2.nodes.Template, name: str | None = None, diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 40e1860b409..a4391061899 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -305,7 +305,7 @@ async def async_initialize_triggers( variables: TemplateVarsType = None, ) -> CALLBACK_TYPE | None: """Initialize triggers.""" - triggers = [] + triggers: list[Coroutine[Any, Any, CALLBACK_TYPE]] = [] for idx, conf in enumerate(trigger_config): # Skip triggers that are not enabled if not conf.get(CONF_ENABLED, True): @@ -338,8 +338,10 @@ async def async_initialize_triggers( log_cb(logging.ERROR, f"Got error '{result}' when setting up triggers for") elif isinstance(result, Exception): log_cb(logging.ERROR, "Error setting up trigger", exc_info=result) + elif isinstance(result, BaseException): + raise result from None elif result is None: - log_cb( + log_cb( # type: ignore[unreachable] logging.ERROR, "Unknown error while setting up trigger (empty result)" ) else: diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 2b570009a57..606b90e6005 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -16,7 +16,14 @@ import requests from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + HassJob, + HassJobType, + HomeAssistant, + callback, +) from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, @@ -92,8 +99,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): # Pick a random microsecond in range 0.05..0.50 to stagger the refreshes # and avoid a thundering herd. self._microsecond = ( - randint(event.RANDOM_MICROSECOND_MIN, event.RANDOM_MICROSECOND_MAX) - / 10**6 + randint(event.RANDOM_MICROSECOND_MIN, event.RANDOM_MICROSECOND_MAX) / 10**6 ) self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} @@ -104,7 +110,11 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): job_name += f" {name}" if entry := self.config_entry: job_name += f" {entry.title} {entry.domain} {entry.entry_id}" - self._job = HassJob(self._handle_refresh_interval, job_name) + self._job = HassJob( + self._handle_refresh_interval, + job_name, + job_type=HassJobType.Coroutinefunction, + ) self._unsub_refresh: CALLBACK_TYPE | None = None self._unsub_shutdown: CALLBACK_TYPE | None = None self._request_refresh_task: asyncio.TimerHandle | None = None diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 39564846de3..6fb538a5aef 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -403,9 +403,7 @@ async def async_get_zeroconf( hass: HomeAssistant, ) -> dict[str, list[dict[str, str | dict[str, str]]]]: """Return cached list of zeroconf types.""" - zeroconf: dict[ - str, list[dict[str, str | dict[str, str]]] - ] = ZEROCONF.copy() # type: ignore[assignment] + zeroconf: dict[str, list[dict[str, str | dict[str, str]]]] = ZEROCONF.copy() # type: ignore[assignment] integrations = await async_get_custom_components(hass) for integration in integrations.values(): @@ -776,11 +774,9 @@ class Integration: if self._all_dependencies_resolved is not None: return self._all_dependencies_resolved + self._all_dependencies_resolved = False try: dependencies = await _async_component_dependencies(self.hass, self) - dependencies.discard(self.domain) - self._all_dependencies = dependencies - self._all_dependencies_resolved = True except IntegrationNotFound as err: _LOGGER.error( ( @@ -790,7 +786,6 @@ class Integration: self.domain, err.domain, ) - self._all_dependencies_resolved = False except CircularDependency as err: _LOGGER.error( ( @@ -801,7 +796,10 @@ class Integration: err.from_domain, err.to_domain, ) - self._all_dependencies_resolved = False + else: + dependencies.discard(self.domain) + self._all_dependencies = dependencies + self._all_dependencies_resolved = True return self._all_dependencies_resolved @@ -1013,9 +1011,7 @@ def _load_file( Async friendly. """ with suppress(KeyError): - return hass.data[DATA_COMPONENTS][ # type: ignore[no-any-return] - comp_or_platform - ] + return hass.data[DATA_COMPONENTS][comp_or_platform] # type: ignore[no-any-return] cache = hass.data[DATA_COMPONENTS] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 67050e43eeb..e8e45a9393e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,29 +1,33 @@ +# Automatically generated by gen_requirements_all.py, do not edit + aiodiscover==1.5.1 -aiohttp==3.8.5;python_version<'3.12' -aiohttp==3.9.0b0;python_version>='3.12' +aiohttp-fast-url-dispatcher==0.3.0 +aiohttp-zlib-ng==0.1.1 +aiohttp==3.9.1 aiohttp_cors==0.7.0 astral==2.2 async-upnp-client==0.36.2 atomicwrites-homeassistant==1.4.1 attrs==23.1.0 -awesomeversion==23.8.0 +awesomeversion==23.11.0 bcrypt==4.0.1 bleak-retry-connector==3.3.0 bleak==0.21.1 bluetooth-adapters==0.16.1 bluetooth-auto-recovery==1.2.3 -bluetooth-data-tools==1.14.0 +bluetooth-data-tools==1.15.0 certifi>=2021.5.30 ciso8601==2.3.0 -cryptography==41.0.4 -dbus-fast==2.12.0 +cryptography==41.0.7 +dbus-fast==2.14.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 +ha-ffmpeg==3.1.0 hass-nabucasa==0.74.0 -hassil==1.2.5 +hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231030.2 -home-assistant-intents==2023.10.16 +home-assistant-frontend==20231206.0 +home-assistant-intents==2023.12.05 httpx==0.25.0 ifaddr==0.2.0 janus==1.0.0 @@ -46,14 +50,14 @@ pyudev==0.23.2 PyYAML==6.0.1 requests==2.31.0 scapy==2.5.0 -SQLAlchemy==2.0.22 +SQLAlchemy==2.0.23 typing-extensions>=4.8.0,<5.0 ulid-transform==0.9.0 voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.2 -zeroconf==0.119.0 +zeroconf==0.127.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 @@ -149,7 +153,7 @@ pyOpenSSL>=23.1.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.24.3 +protobuf==4.25.1 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 9a63c73590b..0e00d0b75f2 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -40,6 +40,7 @@ PATCHES: dict[str, Any] = {} C_HEAD = "bold" ERROR_STR = "General Errors" +WARNING_STR = "General Warnings" def color(the_color, *args, reset=None): @@ -116,6 +117,18 @@ def run(script_args: list) -> int: dump_dict(config, reset="red") print(color("reset")) + if res["warn"]: + print(color("bold_white", "Incorrect config")) + for domain, config in res["warn"].items(): + domain_info.append(domain) + print( + " ", + color("bold_yellow", domain + ":"), + color("yellow", "", reset="yellow"), + ) + dump_dict(config, reset="yellow") + print(color("reset")) + if domain_info: if "all" in domain_info: print(color("bold_white", "Successful config (all)")) @@ -160,7 +173,8 @@ def check(config_dir, secrets=False): res: dict[str, Any] = { "yaml_files": OrderedDict(), # yaml_files loaded "secrets": OrderedDict(), # secret cache and secrets loaded - "except": OrderedDict(), # exceptions raised (with config) + "except": OrderedDict(), # critical exceptions raised (with config) + "warn": OrderedDict(), # non critical exceptions raised (with config) #'components' is a HomeAssistantConfig # noqa: E265 "secret_cache": {}, } @@ -215,6 +229,12 @@ def check(config_dir, secrets=False): if err.config: res["except"].setdefault(domain, []).append(err.config) + for err in res["components"].warnings: + domain = err.domain or WARNING_STR + res["warn"].setdefault(domain, []).append(err.message) + if err.config: + res["warn"].setdefault(domain, []).append(err.config) + except Exception as err: # pylint: disable=broad-except print(color("red", "Fatal error while loading config:"), str(err)) res["except"].setdefault(ERROR_STR, []).append(str(err)) @@ -270,13 +290,13 @@ def dump_dict(layer, indent_count=3, listi=False, **kwargs): for key, value in sorted(layer.items(), key=sort_dict_key): if isinstance(value, (dict, list)): print(indent_str, str(key) + ":", line_info(value, **kwargs)) - dump_dict(value, indent_count + 2) + dump_dict(value, indent_count + 2, **kwargs) else: - print(indent_str, str(key) + ":", value) + print(indent_str, str(key) + ":", value, line_info(key, **kwargs)) indent_str = indent_count * " " if isinstance(layer, Sequence): for i in layer: if isinstance(i, dict): - dump_dict(i, indent_count + 2, True) + dump_dict(i, indent_count + 2, True, **kwargs) else: print(" ", indent_str, i) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index bf405d5deda..679042bc4e9 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -11,14 +11,13 @@ from types import ModuleType from typing import Any from . import config as conf_util, core, loader, requirements -from .config import async_notify_setup_error from .const import ( EVENT_COMPONENT_LOADED, EVENT_HOMEASSISTANT_START, PLATFORM_FORMAT, Platform, ) -from .core import CALLBACK_TYPE, DOMAIN as HOMEASSISTANT_DOMAIN +from .core import CALLBACK_TYPE, DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback from .exceptions import DependencyError, HomeAssistantError from .helpers.issue_registry import IssueSeverity, async_create_issue from .helpers.typing import ConfigType @@ -56,10 +55,47 @@ DATA_SETUP_TIME = "setup_time" DATA_DEPS_REQS = "deps_reqs_processed" +DATA_PERSISTENT_ERRORS = "bootstrap_persistent_errors" + +NOTIFY_FOR_TRANSLATION_KEYS = [ + "config_validation_err", + "platform_config_validation_err", +] + SLOW_SETUP_WARNING = 10 SLOW_SETUP_MAX_WAIT = 300 +@callback +def async_notify_setup_error( + hass: HomeAssistant, component: str, display_link: str | None = None +) -> None: + """Print a persistent notification. + + This method must be run in the event loop. + """ + # pylint: disable-next=import-outside-toplevel + from .components import persistent_notification + + if (errors := hass.data.get(DATA_PERSISTENT_ERRORS)) is None: + errors = hass.data[DATA_PERSISTENT_ERRORS] = {} + + errors[component] = errors.get(component) or display_link + + message = "The following integrations and platforms could not be set up:\n\n" + + for name, link in errors.items(): + show_logs = f"[Show logs](/config/logs?filter={name})" + part = f"[{name}]({link})" if link else name + message += f" - {part} ({show_logs})\n" + + message += "\nPlease check your config and [logs](/config/logs)." + + persistent_notification.async_create( + hass, message, "Invalid config", "invalid_config" + ) + + @core.callback def async_set_domains_to_be_loaded(hass: core.HomeAssistant, domains: set[str]) -> None: """Set domains that are going to be loaded from the config. @@ -157,7 +193,7 @@ async def _async_process_dependencies( if failed: _LOGGER.error( - "Unable to set up dependencies of %s. Setup failed for dependencies: %s", + "Unable to set up dependencies of '%s'. Setup failed for dependencies: %s", integration.domain, ", ".join(failed), ) @@ -183,7 +219,7 @@ async def _async_setup_component( custom = "" if integration.is_built_in else "custom integration " link = integration.documentation _LOGGER.error( - "Setup failed for %s%s: %s", custom, domain, msg, exc_info=exc_info + "Setup failed for %s'%s': %s", custom, domain, msg, exc_info=exc_info ) async_notify_setup_error(hass, domain, link) @@ -217,10 +253,18 @@ async def _async_setup_component( log_error(f"Unable to import component: {err}", err) return False - processed_config = await conf_util.async_process_component_config( + integration_config_info = await conf_util.async_process_component_config( hass, config, integration ) - + processed_config = conf_util.async_handle_component_errors( + hass, integration_config_info, integration + ) + for platform_exception in integration_config_info.exception_info_list: + if platform_exception.translation_key not in NOTIFY_FOR_TRANSLATION_KEYS: + continue + async_notify_setup_error( + hass, platform_exception.platform_name, platform_exception.integration_link + ) if processed_config is None: log_error("Invalid config.") return False @@ -234,8 +278,8 @@ async def _async_setup_component( ): _LOGGER.error( ( - "The %s integration does not support YAML setup, please remove it from " - "your configuration" + "The '%s' integration does not support YAML setup, please remove it " + "from your configuration" ), domain, ) @@ -289,7 +333,7 @@ async def _async_setup_component( except asyncio.TimeoutError: _LOGGER.error( ( - "Setup of %s is taking longer than %s seconds." + "Setup of '%s' is taking longer than %s seconds." " Startup will proceed without waiting any longer" ), domain, @@ -356,7 +400,9 @@ async def async_prepare_setup_platform( def log_error(msg: str) -> None: """Log helper.""" - _LOGGER.error("Unable to prepare setup for platform %s: %s", platform_path, msg) + _LOGGER.error( + "Unable to prepare setup for platform '%s': %s", platform_path, msg + ) async_notify_setup_error(hass, platform_path) try: diff --git a/homeassistant/strings.json b/homeassistant/strings.json index f41380fc9e5..6e6499e0d19 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -126,6 +126,8 @@ "oauth2_authorize_url_timeout": "Timeout generating authorize URL.", "oauth2_no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", "oauth2_user_rejected_authorize": "Account linking rejected: {error}", + "oauth2_unauthorized": "OAuth authorization error while obtaining access token.", + "oauth2_failed": "Error while obtaining access token.", "reauth_successful": "Re-authentication was successful", "unknown_authorize_url_generation": "Unknown error generating an authorize URL.", "cloud_not_connected": "Not connected to Home Assistant Cloud." diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 7f81c281340..ac18d43727c 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -57,7 +57,8 @@ def json_loads_object(__obj: bytes | bytearray | memoryview | str) -> JsonObject def load_json( - filename: str | PathLike, default: JsonValueType = _SENTINEL # type: ignore[assignment] + filename: str | PathLike, + default: JsonValueType = _SENTINEL, # type: ignore[assignment] ) -> JsonValueType: """Load JSON data from a file. @@ -79,7 +80,8 @@ def load_json( def load_json_array( - filename: str | PathLike, default: JsonArrayType = _SENTINEL # type: ignore[assignment] + filename: str | PathLike, + default: JsonArrayType = _SENTINEL, # type: ignore[assignment] ) -> JsonArrayType: """Load JSON data from a file and return as list. @@ -98,7 +100,8 @@ def load_json_array( def load_json_object( - filename: str | PathLike, default: JsonObjectType = _SENTINEL # type: ignore[assignment] + filename: str | PathLike, + default: JsonObjectType = _SENTINEL, # type: ignore[assignment] ) -> JsonObjectType: """Load JSON data from a file and return as dict. diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py index 44fcaa07067..b2ef7330660 100644 --- a/homeassistant/util/location.py +++ b/homeassistant/util/location.py @@ -129,6 +129,7 @@ def vincenty( uSq = cosSqAlpha * (AXIS_A**2 - AXIS_B**2) / (AXIS_B**2) A = 1 + uSq / 16384 * (4096 + uSq * (-768 + uSq * (320 - 175 * uSq))) B = uSq / 1024 * (256 + uSq * (-128 + uSq * (74 - 47 * uSq))) + # fmt: off deltaSigma = ( B * sinSigma @@ -141,11 +142,12 @@ def vincenty( - B / 6 * cos2SigmaM - * (-3 + 4 * sinSigma**2) - * (-3 + 4 * cos2SigmaM**2) + * (-3 + 4 * sinSigma ** 2) + * (-3 + 4 * cos2SigmaM ** 2) ) ) ) + # fmt: on s = AXIS_B * A * (sigma - deltaSigma) s /= 1000 # Conversion of meters to kilometers diff --git a/homeassistant/util/ulid.py b/homeassistant/util/ulid.py index 643286cedb9..818b8015549 100644 --- a/homeassistant/util/ulid.py +++ b/homeassistant/util/ulid.py @@ -1,11 +1,22 @@ """Helpers to generate ulids.""" from __future__ import annotations -import time +from ulid_transform import ( + bytes_to_ulid, + ulid_at_time, + ulid_hex, + ulid_now, + ulid_to_bytes, +) -from ulid_transform import bytes_to_ulid, ulid_at_time, ulid_hex, ulid_to_bytes - -__all__ = ["ulid", "ulid_hex", "ulid_at_time", "ulid_to_bytes", "bytes_to_ulid"] +__all__ = [ + "ulid", + "ulid_hex", + "ulid_at_time", + "ulid_to_bytes", + "bytes_to_ulid", + "ulid_now", +] def ulid(timestamp: float | None = None) -> str: @@ -25,4 +36,4 @@ def ulid(timestamp: float | None = None) -> str: import ulid ulid.parse(ulid_util.ulid()) """ - return ulid_at_time(timestamp or time.time()) + return ulid_now() if timestamp is None else ulid_at_time(timestamp) diff --git a/homeassistant/util/yaml/dumper.py b/homeassistant/util/yaml/dumper.py index a3fba653042..65747d1fd3e 100644 --- a/homeassistant/util/yaml/dumper.py +++ b/homeassistant/util/yaml/dumper.py @@ -4,7 +4,7 @@ from typing import Any import yaml -from .objects import Input, NodeDictClass, NodeListClass +from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass # mypy: allow-untyped-calls, no-warn-return-any @@ -84,6 +84,11 @@ add_representer( lambda dumper, value: dumper.represent_sequence("tag:yaml.org,2002:seq", value), ) +add_representer( + NodeStrClass, + lambda dumper, value: dumper.represent_scalar("tag:yaml.org,2002:str", str(value)), +) + add_representer( Input, lambda dumper, value: dumper.represent_scalar("!input", value.name), diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 5f18a729130..4a14afb53b2 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -1,7 +1,8 @@ """Custom loader.""" from __future__ import annotations -from collections.abc import Iterator +from collections.abc import Callable, Iterator +from contextlib import suppress import fnmatch from io import StringIO, TextIOWrapper import logging @@ -22,6 +23,7 @@ except ImportError: ) from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.frame import report from .const import SECRET_YAML from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass @@ -101,48 +103,11 @@ class Secrets: return secrets -class SafeLoader(FastestAvailableSafeLoader): - """The fastest available safe loader.""" +class _LoaderMixin: + """Mixin class with extensions for YAML loader.""" - def __init__(self, stream: Any, secrets: Secrets | None = None) -> None: - """Initialize a safe line loader.""" - self.stream = stream - if isinstance(stream, str): - self.name = "" - elif isinstance(stream, bytes): - self.name = "" - else: - self.name = getattr(stream, "name", "") - super().__init__(stream) - self.secrets = secrets - - def get_name(self) -> str: - """Get the name of the loader.""" - return self.name - - def get_stream_name(self) -> str: - """Get the name of the stream.""" - return self.stream.name or "" - - -class SafeLineLoader(yaml.SafeLoader): - """Loader class that keeps track of line numbers.""" - - def __init__(self, stream: Any, secrets: Secrets | None = None) -> None: - """Initialize a safe line loader.""" - super().__init__(stream) - self.secrets = secrets - - def compose_node( # type: ignore[override] - self, parent: yaml.nodes.Node, index: int - ) -> yaml.nodes.Node: - """Annotate a node with the first line it was seen.""" - last_line: int = self.line - node: yaml.nodes.Node = super().compose_node( # type: ignore[assignment] - parent, index - ) - node.__line__ = last_line + 1 # type: ignore[attr-defined] - return node + name: str + stream: Any def get_name(self) -> str: """Get the name of the loader.""" @@ -153,7 +118,97 @@ class SafeLineLoader(yaml.SafeLoader): return getattr(self.stream, "name", "") -LoaderType = SafeLineLoader | SafeLoader +class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin): + """The fastest available safe loader, either C or Python.""" + + def __init__(self, stream: Any, secrets: Secrets | None = None) -> None: + """Initialize a safe line loader.""" + self.stream = stream + + # Set name in same way as the Python loader does in yaml.reader.__init__ + if isinstance(stream, str): + self.name = "" + elif isinstance(stream, bytes): + self.name = "" + else: + self.name = getattr(stream, "name", "") + + super().__init__(stream) + self.secrets = secrets + + +class SafeLoader(FastSafeLoader): + """Provided for backwards compatibility. Logs when instantiated.""" + + def __init__(*args: Any, **kwargs: Any) -> None: + """Log a warning and call super.""" + SafeLoader.__report_deprecated() + FastSafeLoader.__init__(*args, **kwargs) + + @classmethod + def add_constructor(cls, tag: str, constructor: Callable) -> None: + """Log a warning and call super.""" + SafeLoader.__report_deprecated() + FastSafeLoader.add_constructor(tag, constructor) + + @classmethod + def add_multi_constructor( + cls, tag_prefix: str, multi_constructor: Callable + ) -> None: + """Log a warning and call super.""" + SafeLoader.__report_deprecated() + FastSafeLoader.add_multi_constructor(tag_prefix, multi_constructor) + + @staticmethod + def __report_deprecated() -> None: + """Log deprecation warning.""" + report( + "uses deprecated 'SafeLoader' instead of 'FastSafeLoader', " + "which will stop working in HA Core 2024.6," + ) + + +class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): + """Python safe loader.""" + + def __init__(self, stream: Any, secrets: Secrets | None = None) -> None: + """Initialize a safe line loader.""" + super().__init__(stream) + self.secrets = secrets + + +class SafeLineLoader(PythonSafeLoader): + """Provided for backwards compatibility. Logs when instantiated.""" + + def __init__(*args: Any, **kwargs: Any) -> None: + """Log a warning and call super.""" + SafeLineLoader.__report_deprecated() + PythonSafeLoader.__init__(*args, **kwargs) + + @classmethod + def add_constructor(cls, tag: str, constructor: Callable) -> None: + """Log a warning and call super.""" + SafeLineLoader.__report_deprecated() + PythonSafeLoader.add_constructor(tag, constructor) + + @classmethod + def add_multi_constructor( + cls, tag_prefix: str, multi_constructor: Callable + ) -> None: + """Log a warning and call super.""" + SafeLineLoader.__report_deprecated() + PythonSafeLoader.add_multi_constructor(tag_prefix, multi_constructor) + + @staticmethod + def __report_deprecated() -> None: + """Log deprecation warning.""" + report( + "uses deprecated 'SafeLineLoader' instead of 'PythonSafeLoader', " + "which will stop working in HA Core 2024.6," + ) + + +LoaderType = FastSafeLoader | PythonSafeLoader def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE: @@ -171,31 +226,31 @@ def parse_yaml( ) -> JSON_TYPE: """Parse YAML with the fastest available loader.""" if not HAS_C_LOADER: - return _parse_yaml_pure_python(content, secrets) + return _parse_yaml_python(content, secrets) try: - return _parse_yaml(SafeLoader, content, secrets) + return _parse_yaml(FastSafeLoader, content, secrets) except yaml.YAMLError: - # Loading failed, so we now load with the slow line loader - # since the C one will not give us line numbers + # Loading failed, so we now load with the Python loader which has more + # readable exceptions if isinstance(content, (StringIO, TextIO, TextIOWrapper)): # Rewind the stream so we can try again content.seek(0, 0) - return _parse_yaml_pure_python(content, secrets) + return _parse_yaml_python(content, secrets) -def _parse_yaml_pure_python( +def _parse_yaml_python( content: str | TextIO | StringIO, secrets: Secrets | None = None ) -> JSON_TYPE: - """Parse YAML with the pure python loader (this is very slow).""" + """Parse YAML with the python loader (this is very slow).""" try: - return _parse_yaml(SafeLineLoader, content, secrets) + return _parse_yaml(PythonSafeLoader, content, secrets) except yaml.YAMLError as exc: _LOGGER.error(str(exc)) raise HomeAssistantError(exc) from exc def _parse_yaml( - loader: type[SafeLoader] | type[SafeLineLoader], + loader: type[FastSafeLoader] | type[PythonSafeLoader], content: str | TextIO, secrets: Secrets | None = None, ) -> JSON_TYPE: @@ -239,13 +294,14 @@ def _add_reference( # type: ignore[no-untyped-def] obj = NodeListClass(obj) if isinstance(obj, str): obj = NodeStrClass(obj) - setattr(obj, "__config_file__", loader.get_name()) - setattr(obj, "__line__", node.start_mark.line) + with suppress(AttributeError): + setattr(obj, "__config_file__", loader.get_name()) + setattr(obj, "__line__", node.start_mark.line + 1) return obj def _include_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: - """Load another YAML file and embeds it using the !include tag. + """Load another YAML file and embed it using the !include tag. Example: device_tracker: !include device_tracker.yaml @@ -347,7 +403,12 @@ def _handle_mapping_tag( raise yaml.MarkedYAMLError( context=f'invalid key: "{key}"', context_mark=yaml.Mark( - fname, 0, line, -1, None, None # type: ignore[arg-type] + fname, + 0, + line, + -1, + None, + None, # type: ignore[arg-type] ), ) from exc @@ -371,6 +432,16 @@ def _construct_seq(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: return _add_reference(obj, loader, node) +def _handle_scalar_tag( + loader: LoaderType, node: yaml.nodes.ScalarNode +) -> str | int | float | None: + """Add line number and file name to Load YAML sequence.""" + obj = loader.construct_scalar(node) + if not isinstance(obj, str): + return obj + return _add_reference(obj, loader, node) + + def _env_var_yaml(loader: LoaderType, node: yaml.nodes.Node) -> str: """Load environment variables and embed it into the configuration YAML.""" args = node.value.split() @@ -394,12 +465,13 @@ def secret_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: def add_constructor(tag: Any, constructor: Any) -> None: """Add to constructor to all loaders.""" - for yaml_loader in (SafeLoader, SafeLineLoader): + for yaml_loader in (FastSafeLoader, PythonSafeLoader): yaml_loader.add_constructor(tag, constructor) add_constructor("!include", _include_yaml) add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _handle_mapping_tag) +add_constructor(yaml.resolver.BaseResolver.DEFAULT_SCALAR_TAG, _handle_scalar_tag) add_constructor(yaml.resolver.BaseResolver.DEFAULT_SEQUENCE_TAG, _construct_seq) add_constructor("!env_var", _env_var_yaml) add_constructor("!secret", secret_yaml) diff --git a/homeassistant/util/yaml/objects.py b/homeassistant/util/yaml/objects.py index b2320a74d2c..6aedc85cf60 100644 --- a/homeassistant/util/yaml/objects.py +++ b/homeassistant/util/yaml/objects.py @@ -2,7 +2,10 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any +import voluptuous as vol +from voluptuous.schema_builder import _compile_scalar import yaml @@ -13,6 +16,10 @@ class NodeListClass(list): class NodeStrClass(str): """Wrapper class to be able to add attributes on a string.""" + def __voluptuous_compile__(self, schema: vol.Schema) -> Any: + """Needed because vol.Schema.compile does not handle str subclasses.""" + return _compile_scalar(self) + class NodeDictClass(dict): """Wrapper class to be able to add attributes on a dict.""" diff --git a/mypy.ini b/mypy.ini index 92b96e75659..0ed06edaa1d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1561,6 +1561,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.input_text.*] +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.integration.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1771,6 +1781,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.linear_garage_door.*] +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.litejet.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pyproject.toml b/pyproject.toml index eb2ca031685..b6bb8649b03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.11.3" +version = "2023.12.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -23,12 +23,14 @@ classifiers = [ ] requires-python = ">=3.11.0" dependencies = [ - "aiohttp==3.9.0b0;python_version>='3.12'", - "aiohttp==3.8.5;python_version<'3.12'", + "aiohttp==3.9.1", + "aiohttp_cors==0.7.0", + "aiohttp-fast-url-dispatcher==0.3.0", + "aiohttp-zlib-ng==0.1.1", "astral==2.2", "attrs==23.1.0", "atomicwrites-homeassistant==1.4.1", - "awesomeversion==23.8.0", + "awesomeversion==23.11.0", "bcrypt==4.0.1", "certifi>=2021.5.30", "ciso8601==2.3.0", @@ -41,7 +43,7 @@ dependencies = [ "lru-dict==1.2.0", "PyJWT==2.8.0", # PyJWT has loose dependency. We want the latest one. - "cryptography==41.0.4", + "cryptography==41.0.7", # pyOpenSSL 23.2.0 is required to work with cryptography 41+ "pyOpenSSL==23.2.0", "orjson==3.9.9", @@ -76,9 +78,6 @@ include-package-data = true [tool.setuptools.packages.find] include = ["homeassistant*"] -[tool.black] -extend-exclude = "/generated/" - [tool.pylint.MAIN] py-version = "3.11" ignore = [ @@ -125,7 +124,7 @@ class-const-naming-style = "any" [tool.pylint."MESSAGES CONTROL"] # Reasons disabled: -# format - handled by black +# format - handled by ruff # locally-disabled - it spams too much # duplicate-code - unavoidable # cyclic-import - doesn't test if both import on load @@ -502,8 +501,6 @@ filterwarnings = [ "ignore:python-telegram-bot is using upstream urllib3:UserWarning:telegram.utils.request", # https://github.com/ludeeus/pytraccar/pull/15 - >1.0.0 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pytraccar.client", - # https://github.com/zopefoundation/RestrictedPython/pull/259 - >7.0a1.dev0 - "ignore:ast\\.(Str|Num) is deprecated and will be removed in Python 3.14:DeprecationWarning:RestrictedPython.transformer", # https://github.com/grahamwetzler/smart-meter-texas/pull/143 - >0.5.3 "ignore:ssl.OP_NO_SSL\\*/ssl.OP_NO_TLS\\* options are deprecated:DeprecationWarning:smart_meter_texas", # https://github.com/Bluetooth-Devices/xiaomi-ble/pull/59 - >0.21.1 @@ -526,8 +523,6 @@ filterwarnings = [ "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated:DeprecationWarning:proto.datetime_helpers", # https://github.com/MatsNl/pyatag/issues/11 - v0.3.7.1 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pyatag.gateway", - # https://github.com/Python-MyQ/Python-MyQ - v3.1.13 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pymyq.(api|account)", # Wrong stacklevel # https://bugs.launchpad.net/beautifulsoup/+bug/2034451 "ignore:It looks like you're parsing an XML document using an HTML parser:UserWarning:bs4.builder", diff --git a/requirements.txt b/requirements.txt index df08234d4db..aa9a0ab0e5a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,16 @@ +# Automatically generated by gen_requirements_all.py, do not edit + -c homeassistant/package_constraints.txt # Home Assistant Core -aiohttp==3.9.0b0;python_version>='3.12' -aiohttp==3.8.5;python_version<'3.12' +aiohttp==3.9.1 +aiohttp_cors==0.7.0 +aiohttp-fast-url-dispatcher==0.3.0 +aiohttp-zlib-ng==0.1.1 astral==2.2 attrs==23.1.0 atomicwrites-homeassistant==1.4.1 -awesomeversion==23.8.0 +awesomeversion==23.11.0 bcrypt==4.0.1 certifi>=2021.5.30 ciso8601==2.3.0 @@ -16,7 +20,7 @@ ifaddr==0.2.0 Jinja2==3.1.2 lru-dict==1.2.0 PyJWT==2.8.0 -cryptography==41.0.4 +cryptography==41.0.7 pyOpenSSL==23.2.0 orjson==3.9.9 packaging>=23.1 diff --git a/requirements_all.txt b/requirements_all.txt index cae2ddb153b..fda92edee3f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1,14 +1,16 @@ # Home Assistant Core, full dependency set +# Automatically generated by gen_requirements_all.py, do not edit + -r requirements.txt # homeassistant.components.aemet -AEMET-OpenData==0.4.5 +AEMET-OpenData==0.4.6 # homeassistant.components.aladdin_connect AIOAladdinConnect==0.1.58 # homeassistant.components.honeywell -AIOSomecomfort==0.0.22 +AIOSomecomfort==0.0.24 # homeassistant.components.adax Adax-local==0.1.5 @@ -19,9 +21,6 @@ Ambiclimate==0.2.1 # homeassistant.components.blinksticklight BlinkStick==1.2.0 -# homeassistant.components.co2signal -CO2Signal==0.4.2 - # homeassistant.components.doorbird DoorBirdPy==2.1.0 @@ -113,7 +112,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.1 # homeassistant.components.vicare -PyViCare==2.28.1 +PyViCare==2.29.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -122,17 +121,14 @@ PyXiaomiGateway==0.14.3 RachioPy==1.0.3 # homeassistant.components.python_script -RestrictedPython==6.2;python_version<'3.12' - -# homeassistant.components.python_script -RestrictedPython==7.0a1.dev0;python_version>='3.12' +RestrictedPython==7.0 # homeassistant.components.remember_the_milk RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.22 +SQLAlchemy==2.0.23 # homeassistant.components.tami4 Tami4EdgeAPI==2.1 @@ -147,10 +143,10 @@ TwitterAPI==2.7.12 WSDiscovery==2.0.0 # homeassistant.components.accuweather -accuweather==2.1.0 +accuweather==2.1.1 # homeassistant.components.adax -adax==0.3.0 +adax==0.4.0 # homeassistant.components.androidtv adb-shell[async]==0.4.4 @@ -159,7 +155,7 @@ adb-shell[async]==0.4.4 adext==0.4.2 # homeassistant.components.adguard -adguardhome==0.6.2 +adguardhome==0.6.3 # homeassistant.components.advantage_air advantage-air==0.4.4 @@ -189,10 +185,10 @@ aio-geojson-usgs-earthquakes==0.2 aio-georss-gdacs==0.8 # homeassistant.components.airq -aioairq==0.2.4 +aioairq==0.3.1 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.5 +aioairzone-cloud==0.3.6 # homeassistant.components.airzone aioairzone==0.6.9 @@ -216,7 +212,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.5.2 +aiocomelit==0.6.2 # homeassistant.components.dhcp aiodiscover==1.5.1 @@ -233,11 +229,14 @@ aioeagle==1.1.0 # homeassistant.components.ecowitt aioecowitt==2023.5.0 +# homeassistant.components.co2signal +aioelectricitymaps==0.1.5 + # homeassistant.components.emonitor aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.2.4 +aioesphomeapi==19.2.1 # homeassistant.components.flo aioflo==2021.11.0 @@ -257,6 +256,12 @@ aioharmony==0.2.10 # homeassistant.components.homekit_controller aiohomekit==3.0.9 +# homeassistant.components.http +aiohttp-fast-url-dispatcher==0.3.0 + +# homeassistant.components.http +aiohttp-zlib-ng==0.1.1 + # homeassistant.components.emulated_hue # homeassistant.components.http aiohttp_cors==0.7.0 @@ -277,10 +282,10 @@ aiokef==0.2.16 aiolifx-effects==0.3.2 # homeassistant.components.lifx -aiolifx-themes==0.4.5 +aiolifx-themes==0.4.10 # homeassistant.components.lifx -aiolifx==0.8.10 +aiolifx==1.0.0 # homeassistant.components.livisi aiolivisi==0.0.19 @@ -348,7 +353,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==6.0.0 +aioshelly==6.1.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -369,16 +374,16 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==65 +aiounifi==67 # homeassistant.components.vlc_telnet aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.4.2 +aiovodafone==0.4.3 # homeassistant.components.waqi -aiowaqi==3.0.0 +aiowaqi==3.0.1 # homeassistant.components.watttime aiowatttime==0.1.1 @@ -387,7 +392,7 @@ aiowatttime==0.1.1 aiowebostv==0.3.3 # homeassistant.components.withings -aiowithings==1.0.2 +aiowithings==2.0.0 # homeassistant.components.yandex_transport aioymaps==1.2.2 @@ -432,7 +437,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.4 +apple_weatherkit==1.1.1 # homeassistant.components.apprise apprise==1.6.0 @@ -458,9 +463,6 @@ asmog==0.0.6 # homeassistant.components.asterisk_mbox asterisk_mbox==0.5.0 -# homeassistant.components.esphome -async-interrupt==1.1.1 - # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms # homeassistant.components.samsungtv @@ -521,10 +523,10 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.36.8 +bellows==0.37.1 # homeassistant.components.bmw_connected_drive -bimmer-connected==0.14.3 +bimmer-connected[china]==0.14.6 # homeassistant.components.bizkaibus bizkaibus==0.1.1 @@ -562,7 +564,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.14.0 +bluetooth-data-tools==1.15.0 # homeassistant.components.bond bond-async==0.2.1 @@ -631,7 +633,6 @@ concord232==0.15 # homeassistant.components.upc_connect connect-box==0.2.8 -# homeassistant.components.eq3btsmart # homeassistant.components.xiaomi_miio construct==2.10.68 @@ -654,7 +655,7 @@ datadog==0.15.0 datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth -dbus-fast==2.12.0 +dbus-fast==2.14.0 # homeassistant.components.debugpy debugpy==1.8.0 @@ -679,6 +680,9 @@ demetriek==0.4.0 # homeassistant.components.denonavr denonavr==0.11.4 +# homeassistant.components.devialet +devialet==1.4.3 + # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 @@ -719,7 +723,7 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.3.0 +easyenergy==1.0.0 # homeassistant.components.ebusd ebusdpy==0.0.17 @@ -731,7 +735,7 @@ ecoaliface==0.4.0 electrickiwi-api==0.8.5 # homeassistant.components.elgato -elgato==5.0.0 +elgato==5.1.1 # homeassistant.components.eliqonline eliqonline==1.2.2 @@ -752,7 +756,7 @@ emulated-roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==0.5.0 +energyzero==1.0.0 # homeassistant.components.enocean enocean==0.50 @@ -785,7 +789,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==0.3.15 +evohome-async==0.4.6 # homeassistant.components.faa_delays faadelays==2023.9.1 @@ -857,7 +861,7 @@ gardena-bluetooth==1.4.0 gassist-text==0.0.10 # homeassistant.components.google -gcal-sync==6.0.1 +gcal-sync==6.0.3 # homeassistant.components.geniushub geniushub-client==0.7.1 @@ -885,7 +889,7 @@ georss-qld-bushfire-alert-client==0.5 getmac==0.8.2 # homeassistant.components.gios -gios==3.2.0 +gios==3.2.2 # homeassistant.components.gitter gitterpy==0.1.7 @@ -897,7 +901,7 @@ glances-api==0.4.3 goalzero==0.2.2 # homeassistant.components.goodwe -goodwe==0.2.31 +goodwe==0.2.32 # homeassistant.components.google_mail # homeassistant.components.google_tasks @@ -940,7 +944,7 @@ greeneye_monitor==3.0.3 greenwavereality==0.5.1 # homeassistant.components.pure_energie -gridnet==4.2.0 +gridnet==5.0.0 # homeassistant.components.growatt_server growattServer==1.3.0 @@ -980,7 +984,7 @@ hass-nabucasa==0.74.0 hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==1.2.5 +hassil==1.5.1 # homeassistant.components.jewish_calendar hdate==0.10.4 @@ -1007,19 +1011,19 @@ hlk-sw16==0.0.9 hole==0.8.0 # homeassistant.components.workday -holidays==0.35 +holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231030.2 +home-assistant-frontend==20231206.0 # homeassistant.components.conversation -home-assistant-intents==2023.10.16 +home-assistant-intents==2023.12.05 # homeassistant.components.home_connect homeconnect==0.7.2 # homeassistant.components.homematicip_cloud -homematicip==1.0.15 +homematicip==1.0.16 # homeassistant.components.home_plus_control homepluscontrol==0.0.5 @@ -1031,7 +1035,7 @@ horimote==0.4.1 httplib2==0.20.4 # homeassistant.components.huawei_lte -huawei-lte-api==1.6.11 +huawei-lte-api==1.7.3 # homeassistant.components.hyperion hyperion-py==0.7.5 @@ -1056,7 +1060,7 @@ ical==6.1.0 icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==2.3 +idasen-ha==2.4 # homeassistant.components.network ifaddr==0.2.0 @@ -1169,6 +1173,9 @@ lightwave==0.24 # homeassistant.components.limitlessled limitlessled==1.1.3 +# homeassistant.components.linear_garage_door +linear-garage-door==0.2.7 + # homeassistant.components.linode linode-api==4.1.9b1 @@ -1239,7 +1246,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.6 +millheater==0.11.7 # homeassistant.components.minio minio==7.1.12 @@ -1251,7 +1258,7 @@ moat-ble==0.1.1 moehlenhoff-alpha2==1.3.0 # homeassistant.components.mopeka -mopeka-iot-ble==0.4.1 +mopeka-iot-ble==0.5.0 # homeassistant.components.motion_blinds motionblinds==0.6.18 @@ -1268,6 +1275,9 @@ mutagen==1.47.0 # homeassistant.components.mutesync mutesync==0.0.1 +# homeassistant.components.permobil +mypermobil==0.1.6 + # homeassistant.components.nad nad-receiver==0.3.0 @@ -1284,7 +1294,7 @@ netdata==1.1.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==2.2.0 +nettigo-air-monitor==2.2.2 # homeassistant.components.neurio_energy neurio==0.3.1 @@ -1299,10 +1309,10 @@ nextcloudmonitor==1.4.0 nextcord==2.0.0a8 # homeassistant.components.nextdns -nextdns==2.0.0 +nextdns==2.1.0 # homeassistant.components.nibe_heatpump -nibe==2.4.0 +nibe==2.5.2 # homeassistant.components.niko_home_control niko-home-control==0.2.1 @@ -1349,7 +1359,7 @@ oauth2client==4.1.3 objgraph==3.5.0 # homeassistant.components.garages_amsterdam -odp-amsterdam==5.3.1 +odp-amsterdam==6.0.0 # homeassistant.components.oem oemthermostat==1.1.1 @@ -1370,7 +1380,7 @@ onvif-zeep-async==3.1.12 open-garage==0.2.0 # homeassistant.components.open_meteo -open-meteo==0.2.1 +open-meteo==0.3.1 # homeassistant.components.openai_conversation openai==0.27.2 @@ -1411,11 +1421,14 @@ oru==0.1.11 # homeassistant.components.orvibo orvibo==1.1.1 +# homeassistant.components.ourgroceries +ourgroceries==1.5.4 + # homeassistant.components.ovo_energy ovoenergy==1.2.0 # homeassistant.components.p1_monitor -p1monitor==2.1.1 +p1monitor==3.0.0 # homeassistant.components.mqtt paho-mqtt==1.6.1 @@ -1463,7 +1476,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.33.2 +plugwise==0.34.3 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1478,7 +1491,7 @@ poolsense==0.0.8 praw==7.5.0 # homeassistant.components.islamic_prayer_times -prayer-times-calculator==0.0.6 +prayer-times-calculator==0.0.10 # homeassistant.components.proliphix proliphix==0.4.1 @@ -1509,7 +1522,7 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==2.0.0 +pvo==2.1.1 # homeassistant.components.canary py-canary==0.5.3 @@ -1557,7 +1570,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2023.10.1 +pyDuotecno==2023.11.1 # homeassistant.components.electrasmart pyElectra==1.2.0 @@ -1599,6 +1612,9 @@ pyairnow==1.2.1 # homeassistant.components.airvisual_pro pyairvisual==2023.08.1 +# homeassistant.components.asuswrt +pyasuswrt==0.1.20 + # homeassistant.components.atag pyatag==0.3.5.3 @@ -1630,7 +1646,7 @@ pybravia==0.3.3 pycarwings2==2.14 # homeassistant.components.cloudflare -pycfdns==2.0.1 +pycfdns==3.0.0 # homeassistant.components.channels pychannels==1.2.3 @@ -1663,7 +1679,7 @@ pydaikin==2.11.1 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==113 +pydeconz==114 # homeassistant.components.delijn pydelijn==1.1.0 @@ -1678,7 +1694,7 @@ pydiscovergy==2.0.5 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2023.10.0 +pydrawise==2023.11.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 @@ -1687,7 +1703,7 @@ pydroid-ipcam==2.0.0 pyebox==1.1.4 # homeassistant.components.ecoforest -pyecoforest==0.3.0 +pyecoforest==0.4.0 # homeassistant.components.econet pyeconet==0.1.22 @@ -1774,7 +1790,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.5.1 +pyinsteon==1.5.2 # homeassistant.components.intesishome pyintesishome==1.8.0 @@ -1816,7 +1832,7 @@ pykmtronic==0.3.0 pykodi==0.2.7 # homeassistant.components.kostal_plenticore -pykoplenti==1.0.0 +pykoplenti==1.2.2 # homeassistant.components.kraken pykrakenapi==0.1.8 @@ -1903,7 +1919,7 @@ pynuki==1.6.2 pynut2==2.1.2 # homeassistant.components.nws -pynws==1.5.1 +pynws==1.6.0 # homeassistant.components.nx584 pynx584==0.5 @@ -1938,7 +1954,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.13.2 +pyoverkiz==1.13.3 # homeassistant.components.openweathermap pyowm==3.2.0 @@ -1992,7 +2008,7 @@ pyrecswitch==1.0.2 pyrepetierng==0.1.0 # homeassistant.components.risco -pyrisco==0.5.7 +pyrisco==0.5.8 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 @@ -2010,10 +2026,10 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2023.10.0 +pyschlage==2023.11.0 # homeassistant.components.sensibo -pysensibo==1.0.35 +pysensibo==1.0.36 # homeassistant.components.zha pyserial-asyncio-fast==0.11 @@ -2075,7 +2091,7 @@ pysqueezebox==0.6.3 pystiebeleltron==0.0.1.dev2 # homeassistant.components.suez_water -pysuez==0.1.19 +pysuez==0.2.0 # homeassistant.components.switchbee pyswitchbee==1.8.0 @@ -2108,10 +2124,7 @@ python-clementine-remote==1.0.1 python-digitalocean==1.13.2 # homeassistant.components.ecobee -python-ecobee-api==0.2.14 - -# homeassistant.components.eq3btsmart -# python-eq3bt==0.2 +python-ecobee-api==0.2.17 # homeassistant.components.etherscan python-etherscan-api==0.0.3 @@ -2132,7 +2145,7 @@ python-gc100==1.0.3a0 python-gitlab==1.6.0 # homeassistant.components.homewizard -python-homewizard-energy==2.1.2 +python-homewizard-energy==4.1.0 # homeassistant.components.hp_ilo python-hpilo==4.3 @@ -2153,7 +2166,7 @@ python-kasa[speedups]==0.5.4 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==4.0.2 +python-matter-server==5.0.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 @@ -2161,9 +2174,6 @@ python-miio==0.5.12 # homeassistant.components.mpd python-mpd2==3.0.5 -# homeassistant.components.myq -python-myq==3.1.13 - # homeassistant.components.mystrom python-mystrom==2.2.0 @@ -2187,13 +2197,13 @@ python-qbittorrent==0.4.3 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.35.0 +python-roborock==0.36.2 # homeassistant.components.smarttub python-smarttub==0.0.36 # homeassistant.components.songpal -python-songpal==0.15.2 +python-songpal==0.16 # homeassistant.components.tado python-tado==0.15.0 @@ -2217,7 +2227,7 @@ pytomorrowio==0.3.6 pytouchline==0.7 # homeassistant.components.traccar -pytraccar==1.0.0 +pytraccar==2.0.0 # homeassistant.components.tradfri pytradfri[async]==9.0.1 @@ -2226,7 +2236,10 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.7 +pytrafikverket==0.3.9.1 + +# homeassistant.components.v2c +pytrydan==0.4.0 # homeassistant.components.usb pyudev==0.23.2 @@ -2301,7 +2314,7 @@ qnapstats==0.4.0 quantum-gateway==0.0.8 # homeassistant.components.radio_browser -radios==0.1.1 +radios==0.2.0 # homeassistant.components.radiotherm radiotherm==2.1.0 @@ -2325,7 +2338,7 @@ renault-api==0.2.0 renson-endura-delta==1.6.0 # homeassistant.components.reolink -reolink-aio==0.7.14 +reolink-aio==0.8.1 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2334,7 +2347,7 @@ rfk101py==0.0.1 rflink==0.0.65 # homeassistant.components.ring -ring-doorbell==0.7.3 +ring-doorbell[listen]==0.8.3 # homeassistant.components.fleetgo ritassist==0.9.2 @@ -2352,7 +2365,7 @@ rokuecp==0.18.1 roombapy==1.6.8 # homeassistant.components.roon -roonapi==0.1.4 +roonapi==0.1.5 # homeassistant.components.rova rova==0.3.0 @@ -2413,10 +2426,10 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.31.0 +sentry-sdk==1.37.1 # homeassistant.components.sfr_box -sfrbox-api==0.0.6 +sfrbox-api==0.0.8 # homeassistant.components.sharkiq sharkiq==1.0.2 @@ -2536,7 +2549,7 @@ switchbot-api==1.2.1 synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridgeconnector==3.8.4 +systembridgeconnector==3.10.0 # homeassistant.components.tailscale tailscale==0.6.0 @@ -2578,7 +2591,7 @@ tesla-wall-connector==1.0.2 thermobeacon-ble==0.6.0 # homeassistant.components.thermopro -thermopro-ble==0.4.5 +thermopro-ble==0.5.0 # homeassistant.components.thermoworks_smoke thermoworks-smoke==0.1.8 @@ -2614,7 +2627,7 @@ tp-connected==0.0.4 tplink-omada-client==1.3.2 # homeassistant.components.transmission -transmission-rpc==4.1.5 +transmission-rpc==7.0.3 # homeassistant.components.twinkly ttls==1.5.1 @@ -2623,7 +2636,7 @@ ttls==1.5.1 tuya-iot-py-sdk==0.6.6 # homeassistant.components.twentemilieu -twentemilieu==2.0.0 +twentemilieu==2.0.1 # homeassistant.components.twilio twilio==6.32.0 @@ -2644,7 +2657,7 @@ unifi-discovery==1.1.7 unifiled==0.11 # homeassistant.components.zha -universal-silabs-flasher==0.0.14 +universal-silabs-flasher==0.0.15 # homeassistant.components.upb upb-lib==0.5.4 @@ -2660,11 +2673,14 @@ url-normalize==1.4.3 # homeassistant.components.uvc uvcclient==0.11.0 +# homeassistant.components.roborock +vacuum-map-parser-roborock==0.1.1 + # homeassistant.components.vallox -vallox-websocket-api==3.3.0 +vallox-websocket-api==4.0.2 # homeassistant.components.rdw -vehicle==2.0.0 +vehicle==2.2.1 # homeassistant.components.velbus velbus-aio==2023.11.0 @@ -2734,7 +2750,7 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.2.0 +wyoming==1.3.0 # homeassistant.components.xbox xbox-webapi==2.0.11 @@ -2770,7 +2786,7 @@ yalexs-ble==2.3.2 yalexs==1.10.0 # homeassistant.components.yeelight -yeelight==0.7.13 +yeelight==0.7.14 # homeassistant.components.yeelightsunflower yeelightsunflower==0.0.10 @@ -2785,22 +2801,22 @@ youless-api==1.0.1 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2023.10.13 +yt-dlp==2023.11.16 # homeassistant.components.zamg -zamg==0.3.0 +zamg==0.3.3 # homeassistant.components.zengge zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.119.0 +zeroconf==0.127.0 # homeassistant.components.zeversolar zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.106 +zha-quirks==0.0.107 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.9 @@ -2809,19 +2825,19 @@ zhong-hong-hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.21.1 +zigpy-deconz==0.22.0 # homeassistant.components.zha -zigpy-xbee==0.19.0 +zigpy-xbee==0.20.0 # homeassistant.components.zha -zigpy-zigate==0.11.0 +zigpy-zigate==0.12.0 # homeassistant.components.zha -zigpy-znp==0.11.6 +zigpy-znp==0.12.0 # homeassistant.components.zha -zigpy==0.59.0 +zigpy==0.60.0 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test.txt b/requirements_test.txt index 1dc9139fde7..d880fecaca5 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -11,7 +11,7 @@ astroid==3.0.1 coverage==7.3.2 freezegun==1.2.2 mock-open==1.4.0 -mypy==1.6.1 +mypy==1.7.1 pre-commit==3.5.0 pydantic==1.10.12 pylint==3.0.2 @@ -26,12 +26,12 @@ pytest-test-groups==1.0.3 pytest-sugar==0.9.7 pytest-timeout==2.1.0 pytest-unordered==0.5.2 -pytest-picked==0.4.6 +pytest-picked==0.5.0 pytest-xdist==3.3.1 pytest==7.4.3 requests-mock==1.11.0 respx==0.20.2 -syrupy==4.5.0 +syrupy==4.6.0 tqdm==4.66.1 types-aiofiles==23.2.0.0 types-atomicwrites==1.4.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c910fa8e4bb..675cfa7c646 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -4,13 +4,13 @@ -r requirements_test.txt # homeassistant.components.aemet -AEMET-OpenData==0.4.5 +AEMET-OpenData==0.4.6 # homeassistant.components.aladdin_connect AIOAladdinConnect==0.1.58 # homeassistant.components.honeywell -AIOSomecomfort==0.0.22 +AIOSomecomfort==0.0.24 # homeassistant.components.adax Adax-local==0.1.5 @@ -18,9 +18,6 @@ Adax-local==0.1.5 # homeassistant.components.ambiclimate Ambiclimate==0.2.1 -# homeassistant.components.co2signal -CO2Signal==0.4.2 - # homeassistant.components.doorbird DoorBirdPy==2.1.0 @@ -100,7 +97,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.1 # homeassistant.components.vicare -PyViCare==2.28.1 +PyViCare==2.29.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -109,17 +106,14 @@ PyXiaomiGateway==0.14.3 RachioPy==1.0.3 # homeassistant.components.python_script -RestrictedPython==6.2;python_version<'3.12' - -# homeassistant.components.python_script -RestrictedPython==7.0a1.dev0;python_version>='3.12' +RestrictedPython==7.0 # homeassistant.components.remember_the_milk RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.22 +SQLAlchemy==2.0.23 # homeassistant.components.tami4 Tami4EdgeAPI==2.1 @@ -128,10 +122,10 @@ Tami4EdgeAPI==2.1 WSDiscovery==2.0.0 # homeassistant.components.accuweather -accuweather==2.1.0 +accuweather==2.1.1 # homeassistant.components.adax -adax==0.3.0 +adax==0.4.0 # homeassistant.components.androidtv adb-shell[async]==0.4.4 @@ -140,7 +134,7 @@ adb-shell[async]==0.4.4 adext==0.4.2 # homeassistant.components.adguard -adguardhome==0.6.2 +adguardhome==0.6.3 # homeassistant.components.advantage_air advantage-air==0.4.4 @@ -170,10 +164,10 @@ aio-geojson-usgs-earthquakes==0.2 aio-georss-gdacs==0.8 # homeassistant.components.airq -aioairq==0.2.4 +aioairq==0.3.1 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.5 +aioairzone-cloud==0.3.6 # homeassistant.components.airzone aioairzone==0.6.9 @@ -197,7 +191,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.5.2 +aiocomelit==0.6.2 # homeassistant.components.dhcp aiodiscover==1.5.1 @@ -214,11 +208,14 @@ aioeagle==1.1.0 # homeassistant.components.ecowitt aioecowitt==2023.5.0 +# homeassistant.components.co2signal +aioelectricitymaps==0.1.5 + # homeassistant.components.emonitor aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.2.4 +aioesphomeapi==19.2.1 # homeassistant.components.flo aioflo==2021.11.0 @@ -235,6 +232,12 @@ aioharmony==0.2.10 # homeassistant.components.homekit_controller aiohomekit==3.0.9 +# homeassistant.components.http +aiohttp-fast-url-dispatcher==0.3.0 + +# homeassistant.components.http +aiohttp-zlib-ng==0.1.1 + # homeassistant.components.emulated_hue # homeassistant.components.http aiohttp_cors==0.7.0 @@ -252,10 +255,10 @@ aiokafka==0.7.2 aiolifx-effects==0.3.2 # homeassistant.components.lifx -aiolifx-themes==0.4.5 +aiolifx-themes==0.4.10 # homeassistant.components.lifx -aiolifx==0.8.10 +aiolifx==1.0.0 # homeassistant.components.livisi aiolivisi==0.0.19 @@ -323,7 +326,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==6.0.0 +aioshelly==6.1.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -344,16 +347,16 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==65 +aiounifi==67 # homeassistant.components.vlc_telnet aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.4.2 +aiovodafone==0.4.3 # homeassistant.components.waqi -aiowaqi==3.0.0 +aiowaqi==3.0.1 # homeassistant.components.watttime aiowatttime==0.1.1 @@ -362,7 +365,7 @@ aiowatttime==0.1.1 aiowebostv==0.3.3 # homeassistant.components.withings -aiowithings==1.0.2 +aiowithings==2.0.0 # homeassistant.components.yandex_transport aioymaps==1.2.2 @@ -398,7 +401,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.4 +apple_weatherkit==1.1.1 # homeassistant.components.apprise apprise==1.6.0 @@ -412,9 +415,6 @@ aranet4==2.2.2 # homeassistant.components.arcam_fmj arcam-fmj==1.4.0 -# homeassistant.components.esphome -async-interrupt==1.1.1 - # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms # homeassistant.components.samsungtv @@ -445,10 +445,10 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.36.8 +bellows==0.37.1 # homeassistant.components.bmw_connected_drive -bimmer-connected==0.14.3 +bimmer-connected[china]==0.14.6 # homeassistant.components.bluetooth bleak-retry-connector==3.3.0 @@ -476,7 +476,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.14.0 +bluetooth-data-tools==1.15.0 # homeassistant.components.bond bond-async==0.2.1 @@ -514,7 +514,6 @@ colorlog==6.7.0 # homeassistant.components.color_extractor colorthief==0.2.1 -# homeassistant.components.eq3btsmart # homeassistant.components.xiaomi_miio construct==2.10.68 @@ -537,7 +536,7 @@ datadog==0.15.0 datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth -dbus-fast==2.12.0 +dbus-fast==2.14.0 # homeassistant.components.debugpy debugpy==1.8.0 @@ -556,6 +555,9 @@ demetriek==0.4.0 # homeassistant.components.denonavr denonavr==0.11.4 +# homeassistant.components.devialet +devialet==1.4.3 + # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 @@ -587,13 +589,13 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.3.0 +easyenergy==1.0.0 # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 # homeassistant.components.elgato -elgato==5.0.0 +elgato==5.1.1 # homeassistant.components.elkm1 elkm1-lib==2.2.6 @@ -608,7 +610,7 @@ emulated-roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==0.5.0 +energyzero==1.0.0 # homeassistant.components.enocean enocean==0.50 @@ -631,6 +633,9 @@ eufylife-ble-client==0.1.8 # homeassistant.components.faa_delays faadelays==2023.9.1 +# homeassistant.components.fastdotcom +fastdotcom==0.0.3 + # homeassistant.components.feedreader feedparser==6.0.10 @@ -679,7 +684,7 @@ gardena-bluetooth==1.4.0 gassist-text==0.0.10 # homeassistant.components.google -gcal-sync==6.0.1 +gcal-sync==6.0.3 # homeassistant.components.geocaching geocachingapi==0.2.1 @@ -704,7 +709,7 @@ georss-qld-bushfire-alert-client==0.5 getmac==0.8.2 # homeassistant.components.gios -gios==3.2.0 +gios==3.2.2 # homeassistant.components.glances glances-api==0.4.3 @@ -713,7 +718,7 @@ glances-api==0.4.3 goalzero==0.2.2 # homeassistant.components.goodwe -goodwe==0.2.31 +goodwe==0.2.32 # homeassistant.components.google_mail # homeassistant.components.google_tasks @@ -741,7 +746,7 @@ greeclimate==1.4.1 greeneye_monitor==3.0.3 # homeassistant.components.pure_energie -gridnet==4.2.0 +gridnet==5.0.0 # homeassistant.components.growatt_server growattServer==1.3.0 @@ -775,7 +780,7 @@ habitipy==0.2.0 hass-nabucasa==0.74.0 # homeassistant.components.conversation -hassil==1.2.5 +hassil==1.5.1 # homeassistant.components.jewish_calendar hdate==0.10.4 @@ -793,19 +798,19 @@ hlk-sw16==0.0.9 hole==0.8.0 # homeassistant.components.workday -holidays==0.35 +holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231030.2 +home-assistant-frontend==20231206.0 # homeassistant.components.conversation -home-assistant-intents==2023.10.16 +home-assistant-intents==2023.12.05 # homeassistant.components.home_connect homeconnect==0.7.2 # homeassistant.components.homematicip_cloud -homematicip==1.0.15 +homematicip==1.0.16 # homeassistant.components.home_plus_control homepluscontrol==0.0.5 @@ -814,7 +819,7 @@ homepluscontrol==0.0.5 httplib2==0.20.4 # homeassistant.components.huawei_lte -huawei-lte-api==1.6.11 +huawei-lte-api==1.7.3 # homeassistant.components.hyperion hyperion-py==0.7.5 @@ -833,7 +838,7 @@ ical==6.1.0 icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==2.3 +idasen-ha==2.4 # homeassistant.components.network ifaddr==0.2.0 @@ -910,6 +915,9 @@ libsoundtouch==0.8 # homeassistant.components.life360 life360==6.0.0 +# homeassistant.components.linear_garage_door +linear-garage-door==0.2.7 + # homeassistant.components.logi_circle logi-circle==0.2.3 @@ -962,7 +970,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.6 +millheater==0.11.7 # homeassistant.components.minio minio==7.1.12 @@ -974,7 +982,7 @@ moat-ble==0.1.1 moehlenhoff-alpha2==1.3.0 # homeassistant.components.mopeka -mopeka-iot-ble==0.4.1 +mopeka-iot-ble==0.5.0 # homeassistant.components.motion_blinds motionblinds==0.6.18 @@ -991,6 +999,9 @@ mutagen==1.47.0 # homeassistant.components.mutesync mutesync==0.0.1 +# homeassistant.components.permobil +mypermobil==0.1.6 + # homeassistant.components.keenetic_ndms2 ndms2-client==0.1.2 @@ -1001,7 +1012,7 @@ nessclient==1.0.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==2.2.0 +nettigo-air-monitor==2.2.2 # homeassistant.components.nexia nexia==2.0.7 @@ -1013,10 +1024,10 @@ nextcloudmonitor==1.4.0 nextcord==2.0.0a8 # homeassistant.components.nextdns -nextdns==2.0.0 +nextdns==2.1.0 # homeassistant.components.nibe_heatpump -nibe==2.4.0 +nibe==2.5.2 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 @@ -1048,7 +1059,7 @@ oauth2client==4.1.3 objgraph==3.5.0 # homeassistant.components.garages_amsterdam -odp-amsterdam==5.3.1 +odp-amsterdam==6.0.0 # homeassistant.components.omnilogic omnilogic==0.4.5 @@ -1063,7 +1074,7 @@ onvif-zeep-async==3.1.12 open-garage==0.2.0 # homeassistant.components.open_meteo -open-meteo==0.2.1 +open-meteo==0.3.1 # homeassistant.components.openai_conversation openai==0.27.2 @@ -1080,11 +1091,14 @@ opower==0.0.39 # homeassistant.components.oralb oralb-ble==0.17.6 +# homeassistant.components.ourgroceries +ourgroceries==1.5.4 + # homeassistant.components.ovo_energy ovoenergy==1.2.0 # homeassistant.components.p1_monitor -p1monitor==2.1.1 +p1monitor==3.0.0 # homeassistant.components.mqtt paho-mqtt==1.6.1 @@ -1120,7 +1134,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.33.2 +plugwise==0.34.3 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1132,7 +1146,7 @@ poolsense==0.0.8 praw==7.5.0 # homeassistant.components.islamic_prayer_times -prayer-times-calculator==0.0.6 +prayer-times-calculator==0.0.10 # homeassistant.components.prometheus prometheus-client==0.17.1 @@ -1151,7 +1165,7 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==2.0.0 +pvo==2.1.1 # homeassistant.components.canary py-canary==0.5.3 @@ -1187,7 +1201,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2023.10.1 +pyDuotecno==2023.11.1 # homeassistant.components.electrasmart pyElectra==1.2.0 @@ -1214,6 +1228,9 @@ pyairnow==1.2.1 # homeassistant.components.airvisual_pro pyairvisual==2023.08.1 +# homeassistant.components.asuswrt +pyasuswrt==0.1.20 + # homeassistant.components.atag pyatag==0.3.5.3 @@ -1239,7 +1256,7 @@ pybotvac==0.0.24 pybravia==0.3.3 # homeassistant.components.cloudflare -pycfdns==2.0.1 +pycfdns==3.0.0 # homeassistant.components.comfoconnect pycomfoconnect==0.5.1 @@ -1254,7 +1271,7 @@ pycsspeechtts==1.0.8 pydaikin==2.11.1 # homeassistant.components.deconz -pydeconz==113 +pydeconz==114 # homeassistant.components.dexcom pydexcom==0.2.3 @@ -1263,13 +1280,13 @@ pydexcom==0.2.3 pydiscovergy==2.0.5 # homeassistant.components.hydrawise -pydrawise==2023.10.0 +pydrawise==2023.11.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 # homeassistant.components.ecoforest -pyecoforest==0.3.0 +pyecoforest==0.4.0 # homeassistant.components.econet pyeconet==0.1.22 @@ -1335,7 +1352,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.5.1 +pyinsteon==1.5.2 # homeassistant.components.ipma pyipma==3.0.7 @@ -1368,7 +1385,7 @@ pykmtronic==0.3.0 pykodi==0.2.7 # homeassistant.components.kostal_plenticore -pykoplenti==1.0.0 +pykoplenti==1.2.2 # homeassistant.components.kraken pykrakenapi==0.1.8 @@ -1431,7 +1448,7 @@ pynuki==1.6.2 pynut2==2.1.2 # homeassistant.components.nws -pynws==1.5.1 +pynws==1.6.0 # homeassistant.components.nx584 pynx584==0.5 @@ -1460,7 +1477,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.13.2 +pyoverkiz==1.13.3 # homeassistant.components.openweathermap pyowm==3.2.0 @@ -1499,7 +1516,7 @@ pyqwikswitch==0.93 pyrainbird==4.0.1 # homeassistant.components.risco -pyrisco==0.5.7 +pyrisco==0.5.8 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 @@ -1514,10 +1531,10 @@ pyrympro==0.0.7 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2023.10.0 +pyschlage==2023.11.0 # homeassistant.components.sensibo -pysensibo==1.0.35 +pysensibo==1.0.36 # homeassistant.components.zha pyserial-asyncio-fast==0.11 @@ -1585,13 +1602,13 @@ python-awair==0.2.4 python-bsblan==0.5.16 # homeassistant.components.ecobee -python-ecobee-api==0.2.14 +python-ecobee-api==0.2.17 # homeassistant.components.fully_kiosk python-fullykiosk==0.0.12 # homeassistant.components.homewizard -python-homewizard-energy==2.1.2 +python-homewizard-energy==4.1.0 # homeassistant.components.izone python-izone==1.2.9 @@ -1603,14 +1620,11 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.5.4 # homeassistant.components.matter -python-matter-server==4.0.2 +python-matter-server==5.0.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 -# homeassistant.components.myq -python-myq==3.1.13 - # homeassistant.components.mystrom python-mystrom==2.2.0 @@ -1628,13 +1642,13 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.3 # homeassistant.components.roborock -python-roborock==0.35.0 +python-roborock==0.36.2 # homeassistant.components.smarttub python-smarttub==0.0.36 # homeassistant.components.songpal -python-songpal==0.15.2 +python-songpal==0.16 # homeassistant.components.tado python-tado==0.15.0 @@ -1649,7 +1663,7 @@ pytile==2023.04.0 pytomorrowio==0.3.6 # homeassistant.components.traccar -pytraccar==1.0.0 +pytraccar==2.0.0 # homeassistant.components.tradfri pytradfri[async]==9.0.1 @@ -1658,7 +1672,10 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.7 +pytrafikverket==0.3.9.1 + +# homeassistant.components.v2c +pytrydan==0.4.0 # homeassistant.components.usb pyudev==0.23.2 @@ -1715,7 +1732,7 @@ qingping-ble==0.8.2 qnapstats==0.4.0 # homeassistant.components.radio_browser -radios==0.1.1 +radios==0.2.0 # homeassistant.components.radiotherm radiotherm==2.1.0 @@ -1733,13 +1750,13 @@ renault-api==0.2.0 renson-endura-delta==1.6.0 # homeassistant.components.reolink -reolink-aio==0.7.14 +reolink-aio==0.8.1 # homeassistant.components.rflink rflink==0.0.65 # homeassistant.components.ring -ring-doorbell==0.7.3 +ring-doorbell[listen]==0.8.3 # homeassistant.components.roku rokuecp==0.18.1 @@ -1748,7 +1765,7 @@ rokuecp==0.18.1 roombapy==1.6.8 # homeassistant.components.roon -roonapi==0.1.4 +roonapi==0.1.5 # homeassistant.components.rpi_power rpi-bad-power==0.1.0 @@ -1791,10 +1808,10 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.31.0 +sentry-sdk==1.37.1 # homeassistant.components.sfr_box -sfrbox-api==0.0.6 +sfrbox-api==0.0.8 # homeassistant.components.sharkiq sharkiq==1.0.2 @@ -1887,7 +1904,7 @@ surepy==0.8.0 switchbot-api==1.2.1 # homeassistant.components.system_bridge -systembridgeconnector==3.8.4 +systembridgeconnector==3.10.0 # homeassistant.components.tailscale tailscale==0.6.0 @@ -1911,7 +1928,7 @@ tesla-wall-connector==1.0.2 thermobeacon-ble==0.6.0 # homeassistant.components.thermopro -thermopro-ble==0.4.5 +thermopro-ble==0.5.0 # homeassistant.components.tilt_ble tilt-ble==0.2.3 @@ -1932,7 +1949,7 @@ total-connect-client==2023.2 tplink-omada-client==1.3.2 # homeassistant.components.transmission -transmission-rpc==4.1.5 +transmission-rpc==7.0.3 # homeassistant.components.twinkly ttls==1.5.1 @@ -1941,7 +1958,7 @@ ttls==1.5.1 tuya-iot-py-sdk==0.6.6 # homeassistant.components.twentemilieu -twentemilieu==2.0.0 +twentemilieu==2.0.1 # homeassistant.components.twilio twilio==6.32.0 @@ -1959,7 +1976,7 @@ ultraheat-api==0.5.7 unifi-discovery==1.1.7 # homeassistant.components.zha -universal-silabs-flasher==0.0.14 +universal-silabs-flasher==0.0.15 # homeassistant.components.upb upb-lib==0.5.4 @@ -1975,11 +1992,14 @@ url-normalize==1.4.3 # homeassistant.components.uvc uvcclient==0.11.0 +# homeassistant.components.roborock +vacuum-map-parser-roborock==0.1.1 + # homeassistant.components.vallox -vallox-websocket-api==3.3.0 +vallox-websocket-api==4.0.2 # homeassistant.components.rdw -vehicle==2.0.0 +vehicle==2.2.1 # homeassistant.components.velbus velbus-aio==2023.11.0 @@ -2034,7 +2054,7 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.2.0 +wyoming==1.3.0 # homeassistant.components.xbox xbox-webapi==2.0.11 @@ -2067,7 +2087,7 @@ yalexs-ble==2.3.2 yalexs==1.10.0 # homeassistant.components.yeelight -yeelight==0.7.13 +yeelight==0.7.14 # homeassistant.components.yolink yolink-api==0.3.1 @@ -2079,34 +2099,34 @@ youless-api==1.0.1 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2023.10.13 +yt-dlp==2023.11.16 # homeassistant.components.zamg -zamg==0.3.0 +zamg==0.3.3 # homeassistant.components.zeroconf -zeroconf==0.119.0 +zeroconf==0.127.0 # homeassistant.components.zeversolar zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.106 +zha-quirks==0.0.107 # homeassistant.components.zha -zigpy-deconz==0.21.1 +zigpy-deconz==0.22.0 # homeassistant.components.zha -zigpy-xbee==0.19.0 +zigpy-xbee==0.20.0 # homeassistant.components.zha -zigpy-zigate==0.11.0 +zigpy-zigate==0.12.0 # homeassistant.components.zha -zigpy-znp==0.11.6 +zigpy-znp==0.12.0 # homeassistant.components.zha -zigpy==0.59.0 +zigpy==0.60.0 # homeassistant.components.zwave_js zwave-js-server-python==0.54.0 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 8891e6e210d..c797db4b7a3 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,6 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit -black==23.10.0 codespell==2.2.2 -ruff==0.1.1 +ruff==0.1.6 yamllint==1.32.0 diff --git a/script/check_format b/script/check_format index bed35ec63e4..09dbb0abe86 100755 --- a/script/check_format +++ b/script/check_format @@ -1,10 +1,10 @@ #!/bin/sh -# Format code with black. +# Format code with ruff-format. cd "$(dirname "$0")/.." -black \ +ruff \ + format \ --check \ - --fast \ --quiet \ homeassistant tests script *.py diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 2668affee96..f6835fdbaf1 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -32,7 +32,6 @@ COMMENT_REQUIREMENTS = ( "pybluez", "pycocotools", "pycups", - "python-eq3bt", "python-gammu", "python-lirc", "pyuserinput", @@ -150,7 +149,7 @@ pyOpenSSL>=23.1.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.24.3 +protobuf==4.25.1 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder @@ -182,12 +181,17 @@ get-mac==1000000000.0.0 charset-normalizer==3.2.0 """ +GENERATED_MESSAGE = ( + f"# Automatically generated by {Path(__file__).name}, do not edit\n\n" +) + IGNORE_PRE_COMMIT_HOOK_ID = ( "check-executables-have-shebangs", "check-json", "no-commit-to-branch", "prettier", "python-typing-update", + "ruff-format", # it's just ruff ) PACKAGE_REGEX = re.compile(r"^(?:--.+\s)?([-_\.\w\d]+).*==.+$") @@ -354,6 +358,7 @@ def generate_requirements_list(reqs: dict[str, list[str]]) -> str: def requirements_output() -> str: """Generate output for requirements.""" output = [ + GENERATED_MESSAGE, "-c homeassistant/package_constraints.txt\n", "\n", "# Home Assistant Core\n", @@ -368,6 +373,7 @@ def requirements_all_output(reqs: dict[str, list[str]]) -> str: """Generate output for requirements_all.""" output = [ "# Home Assistant Core, full dependency set\n", + GENERATED_MESSAGE, "-r requirements.txt\n", ] output.append(generate_requirements_list(reqs)) @@ -379,8 +385,7 @@ def requirements_test_all_output(reqs: dict[str, list[str]]) -> str: """Generate output for test_requirements.""" output = [ "# Home Assistant tests, full dependency set\n", - f"# Automatically generated by {Path(__file__).name}, do not edit\n", - "\n", + GENERATED_MESSAGE, "-r requirements_test.txt\n", ] @@ -389,7 +394,8 @@ def requirements_test_all_output(reqs: dict[str, list[str]]) -> str: for requirement, modules in reqs.items() if any( # Always install requirements that are not part of integrations - not mdl.startswith("homeassistant.components.") or + not mdl.startswith("homeassistant.components.") + or # Install tests for integrations that have tests has_tests(mdl) for mdl in modules @@ -425,7 +431,8 @@ def requirements_pre_commit_output() -> str: def gather_constraints() -> str: """Construct output for constraint file.""" return ( - "\n".join( + GENERATED_MESSAGE + + "\n".join( sorted( { *core_requirements(), diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 32803731ecd..c454c69d141 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -16,6 +16,7 @@ from . import ( coverage, dependencies, dhcp, + docker, json, manifest, metadata, @@ -50,6 +51,7 @@ INTEGRATION_PLUGINS = [ ] HASS_PLUGINS = [ coverage, + docker, mypy_config, metadata, ] diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py new file mode 100644 index 00000000000..3bd44736038 --- /dev/null +++ b/script/hassfest/docker.py @@ -0,0 +1,89 @@ +"""Generate and validate the dockerfile.""" +from homeassistant import core +from homeassistant.util import executor, thread + +from .model import Config, Integration + +DOCKERFILE_TEMPLATE = r"""# Automatically generated by hassfest. +# +# To update, run python3 -m script.hassfest -p docker +ARG BUILD_FROM +FROM ${{BUILD_FROM}} + +# Synchronize with homeassistant/core.py:async_stop +ENV \ + S6_SERVICES_GRACETIME={timeout} + +ARG QEMU_CPU + +WORKDIR /usr/src + +## Setup Home Assistant Core dependencies +COPY requirements.txt homeassistant/ +COPY homeassistant/package_constraints.txt homeassistant/homeassistant/ +RUN \ + pip3 install \ + --only-binary=:all: \ + -r homeassistant/requirements.txt + +COPY requirements_all.txt home_assistant_frontend-* home_assistant_intents-* homeassistant/ +RUN \ + if ls homeassistant/home_assistant_frontend*.whl 1> /dev/null 2>&1; then \ + pip3 install homeassistant/home_assistant_frontend-*.whl; \ + fi \ + && if ls homeassistant/home_assistant_intents*.whl 1> /dev/null 2>&1; then \ + pip3 install homeassistant/home_assistant_intents-*.whl; \ + fi \ + && \ + LD_PRELOAD="/usr/local/lib/libjemalloc.so.2" \ + MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \ + pip3 install \ + --only-binary=:all: \ + -r homeassistant/requirements_all.txt + +## Setup Home Assistant Core +COPY . homeassistant/ +RUN \ + pip3 install \ + --only-binary=:all: \ + -e ./homeassistant \ + && python3 -m compileall \ + homeassistant/homeassistant + +# Home Assistant S6-Overlay +COPY rootfs / + +WORKDIR /config +""" + + +def _generate_dockerfile() -> str: + timeout = ( + core.STAGE_1_SHUTDOWN_TIMEOUT + + core.STAGE_2_SHUTDOWN_TIMEOUT + + core.STAGE_3_SHUTDOWN_TIMEOUT + + executor.EXECUTOR_SHUTDOWN_TIMEOUT + + thread.THREADING_SHUTDOWN_TIMEOUT + + 10 + ) + return DOCKERFILE_TEMPLATE.format(timeout=timeout * 1000) + + +def validate(integrations: dict[str, Integration], config: Config) -> None: + """Validate dockerfile.""" + dockerfile_content = _generate_dockerfile() + config.cache["dockerfile"] = dockerfile_content + + dockerfile_path = config.root / "Dockerfile" + if dockerfile_path.read_text() != dockerfile_content: + config.add_error( + "docker", + "File Dockerfile is not up to date. Run python3 -m script.hassfest", + fixable=True, + ) + + +def generate(integrations: dict[str, Integration], config: Config) -> None: + """Generate dockerfile.""" + dockerfile_path = config.root / "Dockerfile" + dockerfile_path.write_text(config.cache["dockerfile"]) diff --git a/script/hassfest/serializer.py b/script/hassfest/serializer.py index 499ee9d51d9..b56306a8d7e 100644 --- a/script/hassfest/serializer.py +++ b/script/hassfest/serializer.py @@ -2,11 +2,10 @@ from __future__ import annotations from collections.abc import Collection, Iterable, Mapping +import shutil +import subprocess from typing import Any -import black -from black.mode import Mode - DEFAULT_GENERATOR = "script.hassfest" @@ -72,7 +71,14 @@ To update, run python3 -m {generator} {content} """ - return black.format_str(content.strip(), mode=Mode()) + ruff = shutil.which("ruff") + if not ruff: + raise RuntimeError("ruff not found") + return subprocess.check_output( + [ruff, "format", "-"], + input=content.strip(), + encoding="utf-8", + ) def format_python_namespace( diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 4483aacd804..fa2956dd47d 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -215,6 +215,29 @@ def gen_data_entry_schema( return vol.All(*validators) +def gen_issues_schema(config: Config, integration: Integration) -> dict[str, Any]: + """Generate the issues schema.""" + return { + str: vol.All( + cv.has_at_least_one_key("description", "fix_flow"), + vol.Schema( + { + vol.Required("title"): translation_value_validator, + vol.Exclusive( + "description", "fixable" + ): translation_value_validator, + vol.Exclusive("fix_flow", "fixable"): gen_data_entry_schema( + config=config, + integration=integration, + flow_title=UNDEFINED, + require_step_title=False, + ), + }, + ), + ) + } + + def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: """Generate a strings schema.""" return vol.Schema( @@ -266,25 +289,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: vol.Optional("application_credentials"): { vol.Optional("description"): translation_value_validator, }, - vol.Optional("issues"): { - str: vol.All( - cv.has_at_least_one_key("description", "fix_flow"), - vol.Schema( - { - vol.Required("title"): translation_value_validator, - vol.Exclusive( - "description", "fixable" - ): translation_value_validator, - vol.Exclusive("fix_flow", "fixable"): gen_data_entry_schema( - config=config, - integration=integration, - flow_title=UNDEFINED, - require_step_title=False, - ), - }, - ), - ) - }, + vol.Optional("issues"): gen_issues_schema(config, integration), vol.Optional("entity_component"): cv.schema_with_slug_keys( { vol.Optional("name"): str, @@ -328,6 +333,10 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: ), slug_validator=cv.slug, ), + vol.Optional("exceptions"): cv.schema_with_slug_keys( + {vol.Optional("message"): translation_value_validator}, + slug_validator=cv.slug, + ), vol.Optional("services"): cv.schema_with_slug_keys( { vol.Required("name"): translation_value_validator, @@ -358,7 +367,8 @@ def gen_auth_schema(config: Config, integration: Integration) -> vol.Schema: flow_title=REQUIRED, require_step_title=True, ) - } + }, + vol.Optional("issues"): gen_issues_schema(config, integration), } ) diff --git a/script/lint_and_test.py b/script/lint_and_test.py index ee28d4765d6..48809ae4dcd 100755 --- a/script/lint_and_test.py +++ b/script/lint_and_test.py @@ -224,6 +224,7 @@ async def main(): code, _ = await async_exec( "python3", + "-b", "-m", "pytest", "-vv", diff --git a/script/scaffold/__main__.py b/script/scaffold/__main__.py index 8dafd8fa802..ddbd1189e11 100644 --- a/script/scaffold/__main__.py +++ b/script/scaffold/__main__.py @@ -103,10 +103,11 @@ def main(): if args.develop: print("Running tests") - print(f"$ python3 -m pytest -vvv tests/components/{info.domain}") + print(f"$ python3 -b -m pytest -vvv tests/components/{info.domain}") subprocess.run( [ "python3", + "-b", "-m", "pytest", "-vvv", diff --git a/script/scaffold/generate.py b/script/scaffold/generate.py index e31df7ecf0f..197c36e22d1 100644 --- a/script/scaffold/generate.py +++ b/script/scaffold/generate.py @@ -185,6 +185,9 @@ def _custom_tasks(template, info: Info) -> None: "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", diff --git a/script/scaffold/templates/config_flow_helper/integration/__init__.py b/script/scaffold/templates/config_flow_helper/integration/__init__.py index 9b4d4097036..2ad917394b9 100644 --- a/script/scaffold/templates/config_flow_helper/integration/__init__.py +++ b/script/scaffold/templates/config_flow_helper/integration/__init__.py @@ -11,7 +11,7 @@ from .const import DOMAIN async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up NEW_NAME from a config entry.""" # TODO Optionally store an object for your platforms to access - # hass.data[DOMAIN][entry.entry_id] = ... + # hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ... # TODO Optionally validate config entry options before setting up platform diff --git a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py index 8eb21d1cece..213740005e5 100644 --- a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py +++ b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py @@ -25,10 +25,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) # If using a requests-based API lib - hass.data[DOMAIN][entry.entry_id] = api.ConfigEntryAuth(hass, session) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = api.ConfigEntryAuth( + hass, session + ) # If using an aiohttp-based API lib - hass.data[DOMAIN][entry.entry_id] = api.AsyncConfigEntryAuth( + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = api.AsyncConfigEntryAuth( aiohttp_client.async_get_clientsession(hass), session ) diff --git a/script/version_bump.py b/script/version_bump.py index 5e383ab7d4b..3a6c0fa7540 100755 --- a/script/version_bump.py +++ b/script/version_bump.py @@ -177,7 +177,7 @@ def main(): if not arguments.commit: return - subprocess.run(["git", "commit", "-nam", f"Bumped version to {bumped}"], check=True) + subprocess.run(["git", "commit", "-nam", f"Bump version to {bumped}"], check=True) def test_bump_version(): diff --git a/tests/auth/providers/test_command_line.py b/tests/auth/providers/test_command_line.py index 97f8f659397..a92d41a8c5f 100644 --- a/tests/auth/providers/test_command_line.py +++ b/tests/auth/providers/test_command_line.py @@ -50,6 +50,9 @@ async def test_create_new_credential(manager, provider) -> None: user = await manager.async_get_or_create_user(credentials) assert user.is_active + assert len(user.groups) == 1 + assert user.groups[0].id == "system-admin" + assert not user.local_only async def test_match_existing_credentials(store, provider) -> None: @@ -100,6 +103,9 @@ async def test_good_auth_with_meta(manager, provider) -> None: user = await manager.async_get_or_create_user(credentials) assert user.name == "Bob" assert user.is_active + assert len(user.groups) == 1 + assert user.groups[0].id == "system-users" + assert user.local_only async def test_utf_8_username_password(provider) -> None: diff --git a/tests/auth/providers/test_command_line_cmd.sh b/tests/auth/providers/test_command_line_cmd.sh index 0e689e338f1..4cbd7946a29 100755 --- a/tests/auth/providers/test_command_line_cmd.sh +++ b/tests/auth/providers/test_command_line_cmd.sh @@ -4,6 +4,8 @@ if [ "$username" = "good-user" ] && [ "$password" = "good-pass" ]; then echo "Auth should succeed." >&2 if [ "$1" = "--with-meta" ]; then echo "name=Bob" + echo "group=system-users" + echo "local_only=true" fi exit 0 fi diff --git a/tests/auth/providers/test_legacy_api_password.py b/tests/auth/providers/test_legacy_api_password.py index 7c2335f7ccc..3d89c577ebf 100644 --- a/tests/auth/providers/test_legacy_api_password.py +++ b/tests/auth/providers/test_legacy_api_password.py @@ -5,6 +5,12 @@ from homeassistant import auth, data_entry_flow from homeassistant.auth import auth_store from homeassistant.auth.providers import legacy_api_password from homeassistant.core import HomeAssistant +import homeassistant.helpers.issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import ensure_auth_manager_loaded + +CONFIG = {"type": "legacy_api_password", "api_password": "test-password"} @pytest.fixture @@ -16,9 +22,7 @@ def store(hass): @pytest.fixture def provider(hass, store): """Mock provider.""" - return legacy_api_password.LegacyApiPasswordAuthProvider( - hass, store, {"type": "legacy_api_password", "api_password": "test-password"} - ) + return legacy_api_password.LegacyApiPasswordAuthProvider(hass, store, CONFIG) @pytest.fixture @@ -68,3 +72,15 @@ async def test_login_flow_works(hass: HomeAssistant, manager) -> None: flow_id=result["flow_id"], user_input={"password": "test-password"} ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + +async def test_create_repair_issue(hass: HomeAssistant): + """Test legacy api password auth provider creates a reapir issue.""" + hass.auth = await auth.auth_manager_from_config(hass, [CONFIG], []) + ensure_auth_manager_loaded(hass.auth) + await async_setup_component(hass, "auth", {}) + issue_registry: ir.IssueRegistry = ir.async_get(hass) + + assert issue_registry.async_get_issue( + domain="auth", issue_id="deprecated_legacy_api_password" + ) diff --git a/tests/common.py b/tests/common.py index cd522aa3320..b2fa53d28fb 100644 --- a/tests/common.py +++ b/tests/common.py @@ -267,7 +267,7 @@ async def async_test_home_assistant(event_loop, load_registries=True): "homeassistant.helpers.restore_state.RestoreStateData.async_setup_dump", return_value=None, ), patch( - "homeassistant.helpers.restore_state.start.async_at_start" + "homeassistant.helpers.restore_state.start.async_at_start", ): await asyncio.gather( ar.async_load(hass), @@ -297,6 +297,7 @@ def async_mock_service( schema: vol.Schema | None = None, response: ServiceResponse = None, supports_response: SupportsResponse | None = None, + raise_exception: Exception | None = None, ) -> list[ServiceCall]: """Set up a fake service & return a calls log list to this service.""" calls = [] @@ -305,10 +306,15 @@ def async_mock_service( def mock_service_log(call): # pylint: disable=unnecessary-lambda """Mock service call.""" calls.append(call) + if raise_exception is not None: + raise raise_exception return response - if supports_response is None and response is not None: - supports_response = SupportsResponse.OPTIONAL + if supports_response is None: + if response is not None: + supports_response = SupportsResponse.OPTIONAL + else: + supports_response = SupportsResponse.NONE hass.services.async_register( domain, @@ -981,7 +987,10 @@ def assert_setup_component(count, domain=None): async def mock_psc(hass, config_input, integration): """Mock the prepare_setup_component to capture config.""" domain_input = integration.domain - res = await async_process_component_config(hass, config_input, integration) + integration_config_info = await async_process_component_config( + hass, config_input, integration + ) + res = integration_config_info.config config[domain_input] = None if res is None else res.get(domain_input) _LOGGER.debug( "Configuration for %s, Validated: %s, Original %s", @@ -989,7 +998,7 @@ def assert_setup_component(count, domain=None): config[domain_input], config_input.get(domain_input), ) - return res + return integration_config_info assert isinstance(config, dict) with patch("homeassistant.config.async_process_component_config", mock_psc): @@ -1298,11 +1307,12 @@ async def get_system_health_info(hass: HomeAssistant, domain: str) -> dict[str, @contextmanager def mock_config_flow(domain: str, config_flow: type[ConfigFlow]) -> None: """Mock a config flow handler.""" - assert domain not in config_entries.HANDLERS + handler = config_entries.HANDLERS.get(domain) config_entries.HANDLERS[domain] = config_flow _LOGGER.info("Adding mock config flow: %s", domain) yield - config_entries.HANDLERS.pop(domain) + if handler: + config_entries.HANDLERS[domain] = handler def mock_integration( @@ -1336,18 +1346,6 @@ def mock_integration( return integration -def mock_entity_platform( - hass: HomeAssistant, platform_path: str, module: MockPlatform | None -) -> None: - """Mock a entity platform. - - platform_path is in form light.hue. Will create platform - hue.light. - """ - domain, platform_name = platform_path.split(".") - mock_platform(hass, f"{platform_name}.{domain}", module) - - def mock_platform( hass: HomeAssistant, platform_path: str, module: Mock | MockPlatform | None = None ) -> None: diff --git a/tests/components/abode/test_alarm_control_panel.py b/tests/components/abode/test_alarm_control_panel.py index 6924c440bb4..c5500717c5a 100644 --- a/tests/components/abode/test_alarm_control_panel.py +++ b/tests/components/abode/test_alarm_control_panel.py @@ -24,10 +24,11 @@ from .common import setup_platform DEVICE_ID = "alarm_control_panel.abode_alarm" -async def test_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_registry( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, ALARM_DOMAIN) - entity_registry = er.async_get(hass) entry = entity_registry.async_get(DEVICE_ID) # Abode alarm device unique_id is the MAC address diff --git a/tests/components/abode/test_binary_sensor.py b/tests/components/abode/test_binary_sensor.py index 6d7ffec438b..987eea7d891 100644 --- a/tests/components/abode/test_binary_sensor.py +++ b/tests/components/abode/test_binary_sensor.py @@ -17,10 +17,11 @@ from homeassistant.helpers import entity_registry as er from .common import setup_platform -async def test_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_registry( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, BINARY_SENSOR_DOMAIN) - entity_registry = er.async_get(hass) entry = entity_registry.async_get("binary_sensor.front_door") assert entry.unique_id == "2834013428b6035fba7d4054aa7b25a3" diff --git a/tests/components/abode/test_camera.py b/tests/components/abode/test_camera.py index 4bfc16d9689..d0c47eff045 100644 --- a/tests/components/abode/test_camera.py +++ b/tests/components/abode/test_camera.py @@ -10,10 +10,11 @@ from homeassistant.helpers import entity_registry as er from .common import setup_platform -async def test_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_registry( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, CAMERA_DOMAIN) - entity_registry = er.async_get(hass) entry = entity_registry.async_get("camera.test_cam") assert entry.unique_id == "d0a3a1c316891ceb00c20118aae2a133" diff --git a/tests/components/abode/test_cover.py b/tests/components/abode/test_cover.py index a187c0c447e..bc3abd32cd1 100644 --- a/tests/components/abode/test_cover.py +++ b/tests/components/abode/test_cover.py @@ -18,10 +18,11 @@ from .common import setup_platform DEVICE_ID = "cover.garage_door" -async def test_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_registry( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, COVER_DOMAIN) - entity_registry = er.async_get(hass) entry = entity_registry.async_get(DEVICE_ID) assert entry.unique_id == "61cbz3b542d2o33ed2fz02721bda3324" diff --git a/tests/components/abode/test_light.py b/tests/components/abode/test_light.py index 56a924c1226..d7fd719a2b9 100644 --- a/tests/components/abode/test_light.py +++ b/tests/components/abode/test_light.py @@ -27,10 +27,11 @@ from .common import setup_platform DEVICE_ID = "light.living_room_lamp" -async def test_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_registry( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, LIGHT_DOMAIN) - entity_registry = er.async_get(hass) entry = entity_registry.async_get(DEVICE_ID) assert entry.unique_id == "741385f4388b2637df4c6b398fe50581" diff --git a/tests/components/abode/test_lock.py b/tests/components/abode/test_lock.py index ca1a4794bdb..ac988a1ee12 100644 --- a/tests/components/abode/test_lock.py +++ b/tests/components/abode/test_lock.py @@ -18,10 +18,11 @@ from .common import setup_platform DEVICE_ID = "lock.test_lock" -async def test_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_registry( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, LOCK_DOMAIN) - entity_registry = er.async_get(hass) entry = entity_registry.async_get(DEVICE_ID) assert entry.unique_id == "51cab3b545d2o34ed7fz02731bda5324" diff --git a/tests/components/abode/test_sensor.py b/tests/components/abode/test_sensor.py index 755dfbf584e..9f4b3374fc2 100644 --- a/tests/components/abode/test_sensor.py +++ b/tests/components/abode/test_sensor.py @@ -14,10 +14,11 @@ from homeassistant.helpers import entity_registry as er from .common import setup_platform -async def test_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_registry( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, SENSOR_DOMAIN) - entity_registry = er.async_get(hass) entry = entity_registry.async_get("sensor.environment_sensor_humidity") assert entry.unique_id == "13545b21f4bdcd33d9abd461f8443e65-humidity" diff --git a/tests/components/abode/test_switch.py b/tests/components/abode/test_switch.py index a18e554aa39..b5b93d05481 100644 --- a/tests/components/abode/test_switch.py +++ b/tests/components/abode/test_switch.py @@ -24,10 +24,11 @@ DEVICE_ID = "switch.test_switch" DEVICE_UID = "0012a4d3614cb7e2b8c9abea31d2fb2a" -async def test_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_registry( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, SWITCH_DOMAIN) - entity_registry = er.async_get(hass) entry = entity_registry.async_get(AUTOMATION_ID) assert entry.unique_id == AUTOMATION_UID diff --git a/tests/components/accuweather/snapshots/test_weather.ambr b/tests/components/accuweather/snapshots/test_weather.ambr index 521393af71b..081e7bf595a 100644 --- a/tests/components/accuweather/snapshots/test_weather.ambr +++ b/tests/components/accuweather/snapshots/test_weather.ambr @@ -75,6 +75,238 @@ ]), }) # --- +# name: test_forecast_service[forecast] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 58, + 'condition': 'lightning-rainy', + 'datetime': '2020-07-26T05:00:00+00:00', + 'precipitation': 2.5, + 'precipitation_probability': 60, + 'temperature': 29.5, + 'templow': 15.4, + 'uv_index': 5, + 'wind_bearing': 166, + 'wind_gust_speed': 29.6, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 28.9, + 'cloud_coverage': 52, + 'condition': 'partlycloudy', + 'datetime': '2020-07-27T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 26.2, + 'templow': 15.9, + 'uv_index': 7, + 'wind_bearing': 297, + 'wind_gust_speed': 14.8, + 'wind_speed': 9.3, + }), + dict({ + 'apparent_temperature': 31.6, + 'cloud_coverage': 65, + 'condition': 'partlycloudy', + 'datetime': '2020-07-28T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 31.7, + 'templow': 16.8, + 'uv_index': 7, + 'wind_bearing': 198, + 'wind_gust_speed': 24.1, + 'wind_speed': 16.7, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 45, + 'condition': 'partlycloudy', + 'datetime': '2020-07-29T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 9, + 'temperature': 24.0, + 'templow': 11.7, + 'uv_index': 6, + 'wind_bearing': 293, + 'wind_gust_speed': 24.1, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 22.2, + 'cloud_coverage': 50, + 'condition': 'partlycloudy', + 'datetime': '2020-07-30T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'temperature': 21.4, + 'templow': 12.2, + 'uv_index': 7, + 'wind_bearing': 280, + 'wind_gust_speed': 27.8, + 'wind_speed': 18.5, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecast] + dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 58, + 'condition': 'lightning-rainy', + 'datetime': '2020-07-26T05:00:00+00:00', + 'precipitation': 2.5, + 'precipitation_probability': 60, + 'temperature': 29.5, + 'templow': 15.4, + 'uv_index': 5, + 'wind_bearing': 166, + 'wind_gust_speed': 29.6, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 28.9, + 'cloud_coverage': 52, + 'condition': 'partlycloudy', + 'datetime': '2020-07-27T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 26.2, + 'templow': 15.9, + 'uv_index': 7, + 'wind_bearing': 297, + 'wind_gust_speed': 14.8, + 'wind_speed': 9.3, + }), + dict({ + 'apparent_temperature': 31.6, + 'cloud_coverage': 65, + 'condition': 'partlycloudy', + 'datetime': '2020-07-28T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 31.7, + 'templow': 16.8, + 'uv_index': 7, + 'wind_bearing': 198, + 'wind_gust_speed': 24.1, + 'wind_speed': 16.7, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 45, + 'condition': 'partlycloudy', + 'datetime': '2020-07-29T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 9, + 'temperature': 24.0, + 'templow': 11.7, + 'uv_index': 6, + 'wind_bearing': 293, + 'wind_gust_speed': 24.1, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 22.2, + 'cloud_coverage': 50, + 'condition': 'partlycloudy', + 'datetime': '2020-07-30T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'temperature': 21.4, + 'templow': 12.2, + 'uv_index': 7, + 'wind_bearing': 280, + 'wind_gust_speed': 27.8, + 'wind_speed': 18.5, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecasts] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 58, + 'condition': 'lightning-rainy', + 'datetime': '2020-07-26T05:00:00+00:00', + 'precipitation': 2.5, + 'precipitation_probability': 60, + 'temperature': 29.5, + 'templow': 15.4, + 'uv_index': 5, + 'wind_bearing': 166, + 'wind_gust_speed': 29.6, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 28.9, + 'cloud_coverage': 52, + 'condition': 'partlycloudy', + 'datetime': '2020-07-27T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 26.2, + 'templow': 15.9, + 'uv_index': 7, + 'wind_bearing': 297, + 'wind_gust_speed': 14.8, + 'wind_speed': 9.3, + }), + dict({ + 'apparent_temperature': 31.6, + 'cloud_coverage': 65, + 'condition': 'partlycloudy', + 'datetime': '2020-07-28T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 31.7, + 'templow': 16.8, + 'uv_index': 7, + 'wind_bearing': 198, + 'wind_gust_speed': 24.1, + 'wind_speed': 16.7, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 45, + 'condition': 'partlycloudy', + 'datetime': '2020-07-29T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 9, + 'temperature': 24.0, + 'templow': 11.7, + 'uv_index': 6, + 'wind_bearing': 293, + 'wind_gust_speed': 24.1, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 22.2, + 'cloud_coverage': 50, + 'condition': 'partlycloudy', + 'datetime': '2020-07-30T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'temperature': 21.4, + 'templow': 12.2, + 'uv_index': 7, + 'wind_bearing': 280, + 'wind_gust_speed': 27.8, + 'wind_speed': 18.5, + }), + ]), + }), + }) +# --- # name: test_forecast_subscription list([ dict({ diff --git a/tests/components/accuweather/test_init.py b/tests/components/accuweather/test_init.py index c7f79b487b5..342cc2f5914 100644 --- a/tests/components/accuweather/test_init.py +++ b/tests/components/accuweather/test_init.py @@ -117,11 +117,11 @@ async def test_update_interval_forecast(hass: HomeAssistant) -> None: assert mock_forecast.call_count == 1 -async def test_remove_ozone_sensors(hass: HomeAssistant) -> None: +async def test_remove_ozone_sensors( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test remove ozone sensors from registry.""" - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( SENSOR_PLATFORM, DOMAIN, "0123456-ozone-0", @@ -131,5 +131,5 @@ async def test_remove_ozone_sensors(hass: HomeAssistant) -> None: await init_integration(hass) - entry = registry.async_get("sensor.home_ozone_0d") + entry = entity_registry.async_get("sensor.home_ozone_0d") assert entry is None diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py index a7a94894be4..eb5e26a8e20 100644 --- a/tests/components/accuweather/test_sensor.py +++ b/tests/components/accuweather/test_sensor.py @@ -42,11 +42,12 @@ from tests.common import ( async def test_sensor_without_forecast( - hass: HomeAssistant, entity_registry_enabled_by_default: None + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + entity_registry: er.EntityRegistry, ) -> None: """Test states of the sensor without forecast.""" await init_integration(hass) - registry = er.async_get(hass) state = hass.states.get("sensor.home_cloud_ceiling") assert state @@ -57,7 +58,7 @@ async def test_sensor_without_forecast( assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DISTANCE - entry = registry.async_get("sensor.home_cloud_ceiling") + entry = entity_registry.async_get("sensor.home_cloud_ceiling") assert entry assert entry.unique_id == "0123456-ceiling" assert entry.options["sensor"] == {"suggested_display_precision": 0} @@ -78,7 +79,7 @@ async def test_sensor_without_forecast( == SensorDeviceClass.PRECIPITATION_INTENSITY ) - entry = registry.async_get("sensor.home_precipitation") + entry = entity_registry.async_get("sensor.home_precipitation") assert entry assert entry.unique_id == "0123456-precipitation" @@ -91,7 +92,7 @@ async def test_sensor_without_forecast( assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_OPTIONS) == ["falling", "rising", "steady"] - entry = registry.async_get("sensor.home_pressure_tendency") + entry = entity_registry.async_get("sensor.home_pressure_tendency") assert entry assert entry.unique_id == "0123456-pressuretendency" assert entry.translation_key == "pressure_tendency" @@ -104,7 +105,7 @@ async def test_sensor_without_forecast( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_realfeel_temperature") + entry = entity_registry.async_get("sensor.home_realfeel_temperature") assert entry assert entry.unique_id == "0123456-realfeeltemperature" @@ -116,7 +117,7 @@ async def test_sensor_without_forecast( assert state.attributes.get("level") == "High" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_uv_index") + entry = entity_registry.async_get("sensor.home_uv_index") assert entry assert entry.unique_id == "0123456-uvindex" @@ -128,7 +129,7 @@ async def test_sensor_without_forecast( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_apparent_temperature") + entry = entity_registry.async_get("sensor.home_apparent_temperature") assert entry assert entry.unique_id == "0123456-apparenttemperature" @@ -140,7 +141,7 @@ async def test_sensor_without_forecast( assert state.attributes.get(ATTR_ICON) == "mdi:weather-cloudy" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_cloud_cover") + entry = entity_registry.async_get("sensor.home_cloud_cover") assert entry assert entry.unique_id == "0123456-cloudcover" @@ -152,7 +153,7 @@ async def test_sensor_without_forecast( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_dew_point") + entry = entity_registry.async_get("sensor.home_dew_point") assert entry assert entry.unique_id == "0123456-dewpoint" @@ -164,7 +165,7 @@ async def test_sensor_without_forecast( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_realfeel_temperature_shade") + entry = entity_registry.async_get("sensor.home_realfeel_temperature_shade") assert entry assert entry.unique_id == "0123456-realfeeltemperatureshade" @@ -176,7 +177,7 @@ async def test_sensor_without_forecast( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_wet_bulb_temperature") + entry = entity_registry.async_get("sensor.home_wet_bulb_temperature") assert entry assert entry.unique_id == "0123456-wetbulbtemperature" @@ -188,7 +189,7 @@ async def test_sensor_without_forecast( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_wind_chill_temperature") + entry = entity_registry.async_get("sensor.home_wind_chill_temperature") assert entry assert entry.unique_id == "0123456-windchilltemperature" @@ -204,7 +205,7 @@ async def test_sensor_without_forecast( assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - entry = registry.async_get("sensor.home_wind_gust_speed") + entry = entity_registry.async_get("sensor.home_wind_gust_speed") assert entry assert entry.unique_id == "0123456-windgust" @@ -220,17 +221,18 @@ async def test_sensor_without_forecast( assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - entry = registry.async_get("sensor.home_wind_speed") + entry = entity_registry.async_get("sensor.home_wind_speed") assert entry assert entry.unique_id == "0123456-wind" async def test_sensor_with_forecast( - hass: HomeAssistant, entity_registry_enabled_by_default: None + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + entity_registry: er.EntityRegistry, ) -> None: """Test states of the sensor with forecast.""" await init_integration(hass, forecast=True) - registry = er.async_get(hass) state = hass.states.get("sensor.home_hours_of_sun_today") assert state @@ -240,7 +242,7 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.HOURS assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_hours_of_sun_today") + entry = entity_registry.async_get("sensor.home_hours_of_sun_today") assert entry assert entry.unique_id == "0123456-hoursofsun-0" @@ -252,7 +254,7 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_realfeel_temperature_max_today") + entry = entity_registry.async_get("sensor.home_realfeel_temperature_max_today") assert entry state = hass.states.get("sensor.home_realfeel_temperature_min_today") @@ -263,7 +265,7 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_realfeel_temperature_min_today") + entry = entity_registry.async_get("sensor.home_realfeel_temperature_min_today") assert entry assert entry.unique_id == "0123456-realfeeltemperaturemin-0" @@ -275,7 +277,7 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_thunderstorm_probability_today") + entry = entity_registry.async_get("sensor.home_thunderstorm_probability_today") assert entry assert entry.unique_id == "0123456-thunderstormprobabilityday-0" @@ -287,7 +289,7 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_thunderstorm_probability_tonight") + entry = entity_registry.async_get("sensor.home_thunderstorm_probability_tonight") assert entry assert entry.unique_id == "0123456-thunderstormprobabilitynight-0" @@ -300,7 +302,7 @@ async def test_sensor_with_forecast( assert state.attributes.get("level") == "moderate" assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_uv_index_today") + entry = entity_registry.async_get("sensor.home_uv_index_today") assert entry assert entry.unique_id == "0123456-uvindex-0" @@ -327,7 +329,7 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_ICON) == "mdi:weather-cloudy" assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_cloud_cover_today") + entry = entity_registry.async_get("sensor.home_cloud_cover_today") assert entry assert entry.unique_id == "0123456-cloudcoverday-0" @@ -339,7 +341,7 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_ICON) == "mdi:weather-cloudy" assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_cloud_cover_tonight") + entry = entity_registry.async_get("sensor.home_cloud_cover_tonight") assert entry state = hass.states.get("sensor.home_grass_pollen_today") @@ -354,7 +356,7 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_ICON) == "mdi:grass" assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_grass_pollen_today") + entry = entity_registry.async_get("sensor.home_grass_pollen_today") assert entry assert entry.unique_id == "0123456-grass-0" @@ -369,7 +371,7 @@ async def test_sensor_with_forecast( assert state.attributes.get("level") == "low" assert state.attributes.get(ATTR_ICON) == "mdi:blur" - entry = registry.async_get("sensor.home_mold_pollen_today") + entry = entity_registry.async_get("sensor.home_mold_pollen_today") assert entry assert entry.unique_id == "0123456-mold-0" @@ -384,7 +386,7 @@ async def test_sensor_with_forecast( assert state.attributes.get("level") == "low" assert state.attributes.get(ATTR_ICON) == "mdi:sprout" - entry = registry.async_get("sensor.home_ragweed_pollen_today") + entry = entity_registry.async_get("sensor.home_ragweed_pollen_today") assert entry assert entry.unique_id == "0123456-ragweed-0" @@ -396,7 +398,9 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_realfeel_temperature_shade_max_today") + entry = entity_registry.async_get( + "sensor.home_realfeel_temperature_shade_max_today" + ) assert entry assert entry.unique_id == "0123456-realfeeltemperatureshademax-0" @@ -407,7 +411,9 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - entry = registry.async_get("sensor.home_realfeel_temperature_shade_min_today") + entry = entity_registry.async_get( + "sensor.home_realfeel_temperature_shade_min_today" + ) assert entry assert entry.unique_id == "0123456-realfeeltemperatureshademin-0" @@ -423,7 +429,7 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_ICON) == "mdi:tree-outline" assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_tree_pollen_today") + entry = entity_registry.async_get("sensor.home_tree_pollen_today") assert entry assert entry.unique_id == "0123456-tree-0" @@ -439,7 +445,7 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - entry = registry.async_get("sensor.home_wind_speed_today") + entry = entity_registry.async_get("sensor.home_wind_speed_today") assert entry assert entry.unique_id == "0123456-windday-0" @@ -456,7 +462,7 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - entry = registry.async_get("sensor.home_wind_speed_tonight") + entry = entity_registry.async_get("sensor.home_wind_speed_tonight") assert entry assert entry.unique_id == "0123456-windnight-0" @@ -473,7 +479,7 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - entry = registry.async_get("sensor.home_wind_gust_speed_today") + entry = entity_registry.async_get("sensor.home_wind_gust_speed_today") assert entry assert entry.unique_id == "0123456-windgustday-0" @@ -490,11 +496,11 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - entry = registry.async_get("sensor.home_wind_gust_speed_tonight") + entry = entity_registry.async_get("sensor.home_wind_gust_speed_tonight") assert entry assert entry.unique_id == "0123456-windgustnight-0" - entry = registry.async_get("sensor.home_air_quality_today") + entry = entity_registry.async_get("sensor.home_air_quality_today") assert entry assert entry.unique_id == "0123456-airquality-0" @@ -508,7 +514,7 @@ async def test_sensor_with_forecast( == UnitOfIrradiance.WATTS_PER_SQUARE_METER ) - entry = registry.async_get("sensor.home_solar_irradiance_today") + entry = entity_registry.async_get("sensor.home_solar_irradiance_today") assert entry assert entry.unique_id == "0123456-solarirradianceday-0" @@ -522,7 +528,7 @@ async def test_sensor_with_forecast( == UnitOfIrradiance.WATTS_PER_SQUARE_METER ) - entry = registry.async_get("sensor.home_solar_irradiance_tonight") + entry = entity_registry.async_get("sensor.home_solar_irradiance_tonight") assert entry assert entry.unique_id == "0123456-solarirradiancenight-0" @@ -534,7 +540,7 @@ async def test_sensor_with_forecast( ) assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - entry = registry.async_get("sensor.home_condition_today") + entry = entity_registry.async_get("sensor.home_condition_today") assert entry assert entry.unique_id == "0123456-longphraseday-0" @@ -543,7 +549,7 @@ async def test_sensor_with_forecast( assert state.state == "Partly cloudy" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - entry = registry.async_get("sensor.home_condition_tonight") + entry = entity_registry.async_get("sensor.home_condition_tonight") assert entry assert entry.unique_id == "0123456-longphrasenight-0" diff --git a/tests/components/accuweather/test_weather.py b/tests/components/accuweather/test_weather.py index 1d970e322e4..920e5cf82b9 100644 --- a/tests/components/accuweather/test_weather.py +++ b/tests/components/accuweather/test_weather.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import PropertyMock, patch from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.accuweather.const import ATTRIBUTION @@ -31,7 +32,8 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, DOMAIN as WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + LEGACY_SERVICE_GET_FORECAST, + SERVICE_GET_FORECASTS, WeatherEntityFeature, ) from homeassistant.const import ( @@ -55,10 +57,11 @@ from tests.common import ( from tests.typing import WebSocketGenerator -async def test_weather_without_forecast(hass: HomeAssistant) -> None: +async def test_weather_without_forecast( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test states of the weather without forecast.""" await init_integration(hass) - registry = er.async_get(hass) state = hass.states.get("weather.home") assert state @@ -78,15 +81,16 @@ async def test_weather_without_forecast(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert ATTR_SUPPORTED_FEATURES not in state.attributes - entry = registry.async_get("weather.home") + entry = entity_registry.async_get("weather.home") assert entry assert entry.unique_id == "0123456" -async def test_weather_with_forecast(hass: HomeAssistant) -> None: +async def test_weather_with_forecast( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test states of the weather with forecast.""" await init_integration(hass, forecast=True) - registry = er.async_get(hass) state = hass.states.get("weather.home") assert state @@ -120,7 +124,7 @@ async def test_weather_with_forecast(hass: HomeAssistant) -> None: assert forecast.get(ATTR_FORECAST_WIND_GUST_SPEED) == 29.6 assert forecast.get(ATTR_WEATHER_UV_INDEX) == 5 - entry = registry.async_get("weather.home") + entry = entity_registry.async_get("weather.home") assert entry assert entry.unique_id == "0123456" @@ -204,16 +208,24 @@ async def test_unsupported_condition_icon_data(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_FORECAST_CONDITION) is None +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) async def test_forecast_service( hass: HomeAssistant, snapshot: SnapshotAssertion, + service: str, ) -> None: """Test multiple forecast.""" await init_integration(hass, forecast=True) response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.home", "type": "daily", @@ -221,7 +233,6 @@ async def test_forecast_service( blocking=True, return_response=True, ) - assert response["forecast"] != [] assert response == snapshot diff --git a/tests/components/advantage_air/test_binary_sensor.py b/tests/components/advantage_air/test_binary_sensor.py index 8f2183d49c5..c6d055f396a 100644 --- a/tests/components/advantage_air/test_binary_sensor.py +++ b/tests/components/advantage_air/test_binary_sensor.py @@ -20,7 +20,9 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_binary_sensor_async_setup_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test binary sensor setup.""" @@ -34,8 +36,6 @@ async def test_binary_sensor_async_setup_entry( ) await add_mock_config(hass) - registry = er.async_get(hass) - assert len(aioclient_mock.mock_calls) == 1 # Test First Air Filter @@ -44,7 +44,7 @@ async def test_binary_sensor_async_setup_entry( assert state assert state.state == STATE_OFF - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-filter" @@ -54,7 +54,7 @@ async def test_binary_sensor_async_setup_entry( assert state assert state.state == STATE_ON - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac2-filter" @@ -64,7 +64,7 @@ async def test_binary_sensor_async_setup_entry( assert state assert state.state == STATE_ON - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-z01-motion" @@ -74,7 +74,7 @@ async def test_binary_sensor_async_setup_entry( assert state assert state.state == STATE_OFF - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-z02-motion" @@ -83,7 +83,7 @@ async def test_binary_sensor_async_setup_entry( assert not hass.states.get(entity_id) - registry.async_update_entity(entity_id=entity_id, disabled_by=None) + entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done() async_fire_time_changed( @@ -96,7 +96,7 @@ async def test_binary_sensor_async_setup_entry( assert state assert state.state == STATE_ON - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-z01-myzone" @@ -105,7 +105,7 @@ async def test_binary_sensor_async_setup_entry( assert not hass.states.get(entity_id) - registry.async_update_entity(entity_id=entity_id, disabled_by=None) + entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done() async_fire_time_changed( @@ -118,6 +118,6 @@ async def test_binary_sensor_async_setup_entry( assert state assert state.state == STATE_OFF - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-z02-myzone" diff --git a/tests/components/advantage_air/test_climate.py b/tests/components/advantage_air/test_climate.py index b045092d78d..a1eb886cbd0 100644 --- a/tests/components/advantage_air/test_climate.py +++ b/tests/components/advantage_air/test_climate.py @@ -49,7 +49,9 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_climate_async_setup_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test climate platform.""" @@ -63,8 +65,6 @@ async def test_climate_async_setup_entry( ) await add_mock_config(hass) - registry = er.async_get(hass) - # Test MyZone Climate Entity entity_id = "climate.myzone" state = hass.states.get(entity_id) @@ -73,9 +73,9 @@ async def test_climate_async_setup_entry( assert state.attributes.get(ATTR_MIN_TEMP) == 16 assert state.attributes.get(ATTR_MAX_TEMP) == 32 assert state.attributes.get(ATTR_TEMPERATURE) == 24 - assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) is None + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 25 - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1" @@ -173,7 +173,7 @@ async def test_climate_async_setup_entry( assert state.attributes.get(ATTR_TEMPERATURE) == 24 assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 25 - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-z01" @@ -227,7 +227,7 @@ async def test_climate_async_setup_entry( assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 20 assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 24 - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac3" diff --git a/tests/components/advantage_air/test_cover.py b/tests/components/advantage_air/test_cover.py index 80162b448d1..af516d16e6e 100644 --- a/tests/components/advantage_air/test_cover.py +++ b/tests/components/advantage_air/test_cover.py @@ -30,7 +30,9 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_ac_cover( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test cover platform.""" @@ -45,8 +47,6 @@ async def test_ac_cover( await add_mock_config(hass) - registry = er.async_get(hass) - # Test Cover Zone Entity entity_id = "cover.myauto_zone_y" state = hass.states.get(entity_id) @@ -55,7 +55,7 @@ async def test_ac_cover( assert state.attributes.get("device_class") == CoverDeviceClass.DAMPER assert state.attributes.get("current_position") == 100 - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac3-z01" @@ -144,7 +144,9 @@ async def test_ac_cover( async def test_things_cover( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test cover platform.""" @@ -159,8 +161,6 @@ async def test_things_cover( await add_mock_config(hass) - registry = er.async_get(hass) - # Test Blind 1 Entity entity_id = "cover.blind_1" thing_id = "200" @@ -169,7 +169,7 @@ async def test_things_cover( assert state.state == STATE_OPEN assert state.attributes.get("device_class") == CoverDeviceClass.BLIND - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-200" diff --git a/tests/components/advantage_air/test_light.py b/tests/components/advantage_air/test_light.py index a1d38857116..0e27b8aec73 100644 --- a/tests/components/advantage_air/test_light.py +++ b/tests/components/advantage_air/test_light.py @@ -27,7 +27,11 @@ from . import ( from tests.test_util.aiohttp import AiohttpClientMocker -async def test_light(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def test_light( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, +) -> None: """Test light setup.""" aioclient_mock.get( @@ -41,8 +45,6 @@ async def test_light(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) - await add_mock_config(hass) - registry = er.async_get(hass) - # Test Light Entity entity_id = "light.light_a" light_id = "100" @@ -50,7 +52,7 @@ async def test_light(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) - assert state assert state.state == STATE_OFF - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == f"uniqueid-{light_id}" @@ -86,7 +88,7 @@ async def test_light(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) - entity_id = "light.light_b" light_id = "101" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == f"uniqueid-{light_id}" @@ -121,7 +123,9 @@ async def test_light(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) - async def test_things_light( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test things lights.""" @@ -136,8 +140,6 @@ async def test_things_light( await add_mock_config(hass) - registry = er.async_get(hass) - # Test Switch Entity entity_id = "light.thing_light_dimmable" light_id = "204" @@ -145,7 +147,7 @@ async def test_things_light( assert state assert state.state == STATE_ON - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-204" diff --git a/tests/components/advantage_air/test_select.py b/tests/components/advantage_air/test_select.py index 9209862f3c9..553c2e60180 100644 --- a/tests/components/advantage_air/test_select.py +++ b/tests/components/advantage_air/test_select.py @@ -22,7 +22,9 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_select_async_setup_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test select platform.""" @@ -37,8 +39,6 @@ async def test_select_async_setup_entry( await add_mock_config(hass) - registry = er.async_get(hass) - assert len(aioclient_mock.mock_calls) == 1 # Test MyZone Select Entity @@ -47,7 +47,7 @@ async def test_select_async_setup_entry( assert state assert state.state == "Zone open with Sensor" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-myzone" diff --git a/tests/components/advantage_air/test_sensor.py b/tests/components/advantage_air/test_sensor.py index d2c290a97de..e4fab12291d 100644 --- a/tests/components/advantage_air/test_sensor.py +++ b/tests/components/advantage_air/test_sensor.py @@ -26,7 +26,9 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_sensor_platform( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test sensor platform.""" @@ -40,8 +42,6 @@ async def test_sensor_platform( ) await add_mock_config(hass) - registry = er.async_get(hass) - assert len(aioclient_mock.mock_calls) == 1 # Test First TimeToOn Sensor @@ -50,7 +50,7 @@ async def test_sensor_platform( assert state assert int(state.state) == 0 - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-timetoOn" @@ -75,7 +75,7 @@ async def test_sensor_platform( assert state assert int(state.state) == 10 - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-timetoOff" @@ -100,7 +100,7 @@ async def test_sensor_platform( assert state assert int(state.state) == 100 - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-z01-vent" @@ -110,7 +110,7 @@ async def test_sensor_platform( assert state assert int(state.state) == 0 - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-z02-vent" @@ -120,7 +120,7 @@ async def test_sensor_platform( assert state assert int(state.state) == 40 - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-z01-signal" @@ -130,7 +130,7 @@ async def test_sensor_platform( assert state assert int(state.state) == 10 - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-z02-signal" @@ -139,7 +139,7 @@ async def test_sensor_platform( assert not hass.states.get(entity_id) - registry.async_update_entity(entity_id=entity_id, disabled_by=None) + entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done() async_fire_time_changed( @@ -152,6 +152,6 @@ async def test_sensor_platform( assert state assert int(state.state) == 25 - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-z01-temp" diff --git a/tests/components/advantage_air/test_switch.py b/tests/components/advantage_air/test_switch.py index 36851037623..99e4c645e71 100644 --- a/tests/components/advantage_air/test_switch.py +++ b/tests/components/advantage_air/test_switch.py @@ -27,7 +27,9 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_cover_async_setup_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test switch platform.""" @@ -42,15 +44,13 @@ async def test_cover_async_setup_entry( await add_mock_config(hass) - registry = er.async_get(hass) - # Test Switch Entity entity_id = "switch.myzone_fresh_air" state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-freshair" @@ -82,7 +82,9 @@ async def test_cover_async_setup_entry( async def test_things_switch( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test things switches.""" @@ -97,8 +99,6 @@ async def test_things_switch( await add_mock_config(hass) - registry = er.async_get(hass) - # Test Switch Entity entity_id = "switch.relay" thing_id = "205" @@ -106,7 +106,7 @@ async def test_things_switch( assert state assert state.state == STATE_ON - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-205" diff --git a/tests/components/advantage_air/test_update.py b/tests/components/advantage_air/test_update.py index 0e7c7be4436..985641b923b 100644 --- a/tests/components/advantage_air/test_update.py +++ b/tests/components/advantage_air/test_update.py @@ -10,7 +10,9 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_update_platform( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test update platform.""" @@ -20,13 +22,11 @@ async def test_update_platform( ) await add_mock_config(hass) - registry = er.async_get(hass) - entity_id = "update.testname_app" state = hass.states.get(entity_id) assert state assert state.state == STATE_ON - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid" diff --git a/tests/components/aemet/snapshots/test_weather.ambr b/tests/components/aemet/snapshots/test_weather.ambr index 3078cab4480..9a7b79d94ea 100644 --- a/tests/components/aemet/snapshots/test_weather.ambr +++ b/tests/components/aemet/snapshots/test_weather.ambr @@ -14,7 +14,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-11T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 3.0, 'templow': -7.0, 'wind_bearing': 0.0, @@ -23,7 +23,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-12T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': -1.0, 'templow': -13.0, 'wind_bearing': None, @@ -31,7 +31,7 @@ dict({ 'condition': 'sunny', 'datetime': '2021-01-13T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 6.0, 'templow': -11.0, 'wind_bearing': None, @@ -39,7 +39,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-14T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 6.0, 'templow': -7.0, 'wind_bearing': None, @@ -47,7 +47,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-15T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 5.0, 'templow': -4.0, 'wind_bearing': None, @@ -151,6 +151,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-09T21:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 90.0, @@ -160,6 +161,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-09T22:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 45.0, @@ -169,6 +171,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-09T23:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 90.0, @@ -178,7 +181,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 10, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 12.0, @@ -187,6 +191,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T01:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 45.0, @@ -196,6 +201,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T02:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 0.0, @@ -205,6 +211,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T03:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 0.0, @@ -214,6 +221,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T04:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -223,6 +231,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T05:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': -1.0, 'wind_bearing': 0.0, @@ -232,7 +241,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T06:00:00+00:00', - 'precipitation_probability': 10, + 'precipitation': 0.0, + 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 0.0, 'wind_gust_speed': 13.0, @@ -241,6 +251,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T07:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -2.0, 'wind_bearing': 45.0, @@ -250,6 +261,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T08:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -259,6 +271,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T09:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -268,6 +281,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T10:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': 0.0, 'wind_bearing': 45.0, @@ -277,6 +291,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T11:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': 2.0, 'wind_bearing': 45.0, @@ -286,7 +301,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T12:00:00+00:00', - 'precipitation_probability': 15, + 'precipitation': 0.0, + 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, 'wind_gust_speed': 32.0, @@ -295,6 +311,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T13:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -304,6 +321,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T14:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -313,6 +331,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T15:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 4.0, 'wind_bearing': 45.0, @@ -322,6 +341,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T16:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -331,6 +351,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T17:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 2.0, 'wind_bearing': 45.0, @@ -340,7 +361,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T18:00:00+00:00', - 'precipitation_probability': 5, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 24.0, @@ -349,7 +371,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T19:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 25.0, @@ -358,7 +381,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T20:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 25.0, @@ -367,7 +391,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T21:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 24.0, @@ -376,7 +401,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T22:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 0.0, 'wind_bearing': 45.0, 'wind_gust_speed': 27.0, @@ -385,7 +411,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T23:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 0.0, 'wind_bearing': 45.0, 'wind_gust_speed': 30.0, @@ -394,7 +421,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-11T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 30.0, @@ -403,7 +431,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-11T01:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 27.0, @@ -412,7 +441,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T02:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -2.0, 'wind_bearing': 45.0, 'wind_gust_speed': 22.0, @@ -421,7 +451,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T03:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -2.0, 'wind_bearing': 45.0, 'wind_gust_speed': 17.0, @@ -430,7 +461,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T04:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -3.0, 'wind_bearing': 45.0, 'wind_gust_speed': 15.0, @@ -439,7 +471,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T05:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -4.0, 'wind_bearing': 45.0, 'wind_gust_speed': 15.0, @@ -457,6 +490,1454 @@ ]), }) # --- +# name: test_forecast_service[forecast] + dict({ + 'weather.aemet': dict({ + 'forecast': list([ + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T00:00:00+00:00', + 'precipitation_probability': 30, + 'temperature': 4.0, + 'templow': -4.0, + 'wind_bearing': 45.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-11T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 3.0, + 'templow': -7.0, + 'wind_bearing': 0.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-12T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -1.0, + 'templow': -13.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-01-13T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 6.0, + 'templow': -11.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-14T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 6.0, + 'templow': -7.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-15T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 5.0, + 'templow': -4.0, + 'wind_bearing': None, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast].1 + dict({ + 'weather.aemet': dict({ + 'forecast': list([ + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T12:00:00+00:00', + 'precipitation': 3.6, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T13:00:00+00:00', + 'precipitation': 2.7, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 22.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T14:00:00+00:00', + 'precipitation': 0.6, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 14.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T15:00:00+00:00', + 'precipitation': 0.8, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 20.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T16:00:00+00:00', + 'precipitation': 1.4, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 14.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T17:00:00+00:00', + 'precipitation': 1.2, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 9.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T18:00:00+00:00', + 'precipitation': 0.4, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 7.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T19:00:00+00:00', + 'precipitation': 0.3, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T20:00:00+00:00', + 'precipitation': 0.1, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-09T21:00:00+00:00', + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 8.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-09T22:00:00+00:00', + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 9.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-09T23:00:00+00:00', + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T01:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 10.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T02:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T03:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 9.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T04:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T05:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T06:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 9.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T07:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 18.0, + 'wind_speed': 13.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T08:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T09:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 31.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T10:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T11:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': 2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T12:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 22.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T13:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T14:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 4.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 28.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T16:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T17:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T18:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T19:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T20:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T21:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T22:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 27.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T23:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-11T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-11T01:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 27.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T02:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 22.0, + 'wind_speed': 12.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T03:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 17.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T04:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 11.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T05:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -4.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T06:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -4.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 10.0, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecast] + dict({ + 'forecast': list([ + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T00:00:00+00:00', + 'precipitation_probability': 30, + 'temperature': 4.0, + 'templow': -4.0, + 'wind_bearing': 45.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-11T00:00:00+00:00', + 'precipitation_probability': 0, + 'temperature': 3.0, + 'templow': -7.0, + 'wind_bearing': 0.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-12T00:00:00+00:00', + 'precipitation_probability': 0, + 'temperature': -1.0, + 'templow': -13.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-01-13T00:00:00+00:00', + 'precipitation_probability': 0, + 'temperature': 6.0, + 'templow': -11.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-14T00:00:00+00:00', + 'precipitation_probability': 0, + 'temperature': 6.0, + 'templow': -7.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-15T00:00:00+00:00', + 'precipitation_probability': 0, + 'temperature': 5.0, + 'templow': -4.0, + 'wind_bearing': None, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecast].1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T12:00:00+00:00', + 'precipitation': 3.6, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T13:00:00+00:00', + 'precipitation': 2.7, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 22.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T14:00:00+00:00', + 'precipitation': 0.6, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 14.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T15:00:00+00:00', + 'precipitation': 0.8, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 20.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T16:00:00+00:00', + 'precipitation': 1.4, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 14.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T17:00:00+00:00', + 'precipitation': 1.2, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 9.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T18:00:00+00:00', + 'precipitation': 0.4, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 7.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T19:00:00+00:00', + 'precipitation': 0.3, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T20:00:00+00:00', + 'precipitation': 0.1, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-09T21:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 8.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-09T22:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 9.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-09T23:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T00:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T01:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 10.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T02:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T03:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 9.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T04:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T06:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 9.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T07:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 15, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 18.0, + 'wind_speed': 13.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T08:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T09:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 31.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 15, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 15, + 'temperature': 2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T12:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 22.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T13:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T14:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T15:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 4.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 28.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T16:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T17:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T18:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T19:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T20:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T21:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T22:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 27.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T23:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-11T00:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-11T01:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 27.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T02:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 22.0, + 'wind_speed': 12.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T03:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 17.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T04:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': -3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 11.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': -4.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T06:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -4.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 10.0, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecasts] + dict({ + 'weather.aemet': dict({ + 'forecast': list([ + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T00:00:00+00:00', + 'precipitation_probability': 30, + 'temperature': 4.0, + 'templow': -4.0, + 'wind_bearing': 45.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-11T00:00:00+00:00', + 'precipitation_probability': 0, + 'temperature': 3.0, + 'templow': -7.0, + 'wind_bearing': 0.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-12T00:00:00+00:00', + 'precipitation_probability': 0, + 'temperature': -1.0, + 'templow': -13.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-01-13T00:00:00+00:00', + 'precipitation_probability': 0, + 'temperature': 6.0, + 'templow': -11.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-14T00:00:00+00:00', + 'precipitation_probability': 0, + 'temperature': 6.0, + 'templow': -7.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-15T00:00:00+00:00', + 'precipitation_probability': 0, + 'temperature': 5.0, + 'templow': -4.0, + 'wind_bearing': None, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecasts].1 + dict({ + 'weather.aemet': dict({ + 'forecast': list([ + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T12:00:00+00:00', + 'precipitation': 3.6, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T13:00:00+00:00', + 'precipitation': 2.7, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 22.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T14:00:00+00:00', + 'precipitation': 0.6, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 14.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T15:00:00+00:00', + 'precipitation': 0.8, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 20.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T16:00:00+00:00', + 'precipitation': 1.4, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 14.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T17:00:00+00:00', + 'precipitation': 1.2, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 9.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T18:00:00+00:00', + 'precipitation': 0.4, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 7.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T19:00:00+00:00', + 'precipitation': 0.3, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T20:00:00+00:00', + 'precipitation': 0.1, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-09T21:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 8.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-09T22:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 9.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-09T23:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T00:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T01:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 10.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T02:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T03:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 9.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T04:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T06:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 9.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T07:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 15, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 18.0, + 'wind_speed': 13.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T08:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T09:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 31.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 15, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 15, + 'temperature': 2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T12:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 22.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T13:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T14:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T15:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 4.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 28.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T16:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T17:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T18:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T19:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T20:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T21:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T22:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 27.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T23:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-11T00:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-11T01:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 27.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T02:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 22.0, + 'wind_speed': 12.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T03:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 17.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T04:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': -3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 11.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': -4.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T06:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -4.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 10.0, + }), + ]), + }), + }) +# --- # name: test_forecast_subscription[daily] list([ dict({ @@ -471,7 +1952,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-11T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 3.0, 'templow': -7.0, 'wind_bearing': 0.0, @@ -480,7 +1961,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-12T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': -1.0, 'templow': -13.0, 'wind_bearing': None, @@ -488,7 +1969,7 @@ dict({ 'condition': 'sunny', 'datetime': '2021-01-13T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 6.0, 'templow': -11.0, 'wind_bearing': None, @@ -496,7 +1977,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-14T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 6.0, 'templow': -7.0, 'wind_bearing': None, @@ -504,7 +1985,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-15T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 5.0, 'templow': -4.0, 'wind_bearing': None, @@ -525,7 +2006,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-11T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 3.0, 'templow': -7.0, 'wind_bearing': 0.0, @@ -534,7 +2015,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-12T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': -1.0, 'templow': -13.0, 'wind_bearing': None, @@ -542,7 +2023,7 @@ dict({ 'condition': 'sunny', 'datetime': '2021-01-13T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 6.0, 'templow': -11.0, 'wind_bearing': None, @@ -550,7 +2031,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-14T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 6.0, 'templow': -7.0, 'wind_bearing': None, @@ -558,7 +2039,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-15T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 5.0, 'templow': -4.0, 'wind_bearing': None, @@ -660,6 +2141,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-09T21:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 90.0, @@ -669,6 +2151,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-09T22:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 45.0, @@ -678,6 +2161,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-09T23:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 90.0, @@ -687,7 +2171,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 10, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 12.0, @@ -696,6 +2181,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T01:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 45.0, @@ -705,6 +2191,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T02:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 0.0, @@ -714,6 +2201,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T03:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 0.0, @@ -723,6 +2211,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T04:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -732,6 +2221,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T05:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': -1.0, 'wind_bearing': 0.0, @@ -741,7 +2231,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T06:00:00+00:00', - 'precipitation_probability': 10, + 'precipitation': 0.0, + 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 0.0, 'wind_gust_speed': 13.0, @@ -750,6 +2241,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T07:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -2.0, 'wind_bearing': 45.0, @@ -759,6 +2251,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T08:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -768,6 +2261,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T09:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -777,6 +2271,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T10:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': 0.0, 'wind_bearing': 45.0, @@ -786,6 +2281,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T11:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': 2.0, 'wind_bearing': 45.0, @@ -795,7 +2291,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T12:00:00+00:00', - 'precipitation_probability': 15, + 'precipitation': 0.0, + 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, 'wind_gust_speed': 32.0, @@ -804,6 +2301,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T13:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -813,6 +2311,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T14:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -822,6 +2321,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T15:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 4.0, 'wind_bearing': 45.0, @@ -831,6 +2331,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T16:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -840,6 +2341,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T17:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 2.0, 'wind_bearing': 45.0, @@ -849,7 +2351,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T18:00:00+00:00', - 'precipitation_probability': 5, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 24.0, @@ -858,7 +2361,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T19:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 25.0, @@ -867,7 +2371,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T20:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 25.0, @@ -876,7 +2381,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T21:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 24.0, @@ -885,7 +2391,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T22:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 0.0, 'wind_bearing': 45.0, 'wind_gust_speed': 27.0, @@ -894,7 +2401,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T23:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 0.0, 'wind_bearing': 45.0, 'wind_gust_speed': 30.0, @@ -903,7 +2411,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-11T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 30.0, @@ -912,7 +2421,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-11T01:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 27.0, @@ -921,7 +2431,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T02:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -2.0, 'wind_bearing': 45.0, 'wind_gust_speed': 22.0, @@ -930,7 +2441,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T03:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -2.0, 'wind_bearing': 45.0, 'wind_gust_speed': 17.0, @@ -939,7 +2451,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T04:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -3.0, 'wind_bearing': 45.0, 'wind_gust_speed': 15.0, @@ -948,7 +2461,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T05:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -4.0, 'wind_bearing': 45.0, 'wind_gust_speed': 15.0, @@ -1060,6 +2574,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-09T21:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 90.0, @@ -1069,6 +2584,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-09T22:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 45.0, @@ -1078,6 +2594,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-09T23:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 90.0, @@ -1087,7 +2604,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 10, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 12.0, @@ -1096,6 +2614,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T01:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 45.0, @@ -1105,6 +2624,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T02:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 0.0, @@ -1114,6 +2634,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T03:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 0.0, @@ -1123,6 +2644,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T04:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -1132,6 +2654,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T05:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': -1.0, 'wind_bearing': 0.0, @@ -1141,7 +2664,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T06:00:00+00:00', - 'precipitation_probability': 10, + 'precipitation': 0.0, + 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 0.0, 'wind_gust_speed': 13.0, @@ -1150,6 +2674,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T07:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -2.0, 'wind_bearing': 45.0, @@ -1159,6 +2684,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T08:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -1168,6 +2694,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T09:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -1177,6 +2704,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T10:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': 0.0, 'wind_bearing': 45.0, @@ -1186,6 +2714,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T11:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': 2.0, 'wind_bearing': 45.0, @@ -1195,7 +2724,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T12:00:00+00:00', - 'precipitation_probability': 15, + 'precipitation': 0.0, + 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, 'wind_gust_speed': 32.0, @@ -1204,6 +2734,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T13:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -1213,6 +2744,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T14:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -1222,6 +2754,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T15:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 4.0, 'wind_bearing': 45.0, @@ -1231,6 +2764,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T16:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -1240,6 +2774,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T17:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 2.0, 'wind_bearing': 45.0, @@ -1249,7 +2784,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T18:00:00+00:00', - 'precipitation_probability': 5, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 24.0, @@ -1258,7 +2794,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T19:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 25.0, @@ -1267,7 +2804,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T20:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 25.0, @@ -1276,7 +2814,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T21:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 24.0, @@ -1285,7 +2824,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T22:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 0.0, 'wind_bearing': 45.0, 'wind_gust_speed': 27.0, @@ -1294,7 +2834,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T23:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 0.0, 'wind_bearing': 45.0, 'wind_gust_speed': 30.0, @@ -1303,7 +2844,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-11T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 30.0, @@ -1312,7 +2854,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-11T01:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 27.0, @@ -1321,7 +2864,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T02:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -2.0, 'wind_bearing': 45.0, 'wind_gust_speed': 22.0, @@ -1330,7 +2874,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T03:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -2.0, 'wind_bearing': 45.0, 'wind_gust_speed': 17.0, @@ -1339,7 +2884,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T04:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -3.0, 'wind_bearing': 45.0, 'wind_gust_speed': 15.0, @@ -1348,7 +2894,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T05:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -4.0, 'wind_bearing': 45.0, 'wind_gust_speed': 15.0, diff --git a/tests/components/aemet/test_init.py b/tests/components/aemet/test_init.py index 9389acf07c9..7a4f73dc62b 100644 --- a/tests/components/aemet/test_init.py +++ b/tests/components/aemet/test_init.py @@ -1,7 +1,7 @@ """Define tests for the AEMET OpenData init.""" -import asyncio from unittest.mock import patch +from aemet_opendata.exceptions import AemetTimeout from freezegun.api import FrozenDateTimeFactory from homeassistant.components.aemet.const import DOMAIN @@ -83,7 +83,7 @@ async def test_init_api_timeout( freezer.move_to("2021-01-09 12:00:00+00:00") with patch( "homeassistant.components.aemet.AEMET.api_call", - side_effect=asyncio.TimeoutError, + side_effect=AemetTimeout, ): config_entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/aemet/test_weather.py b/tests/components/aemet/test_weather.py index d0042faaaa0..695087bb738 100644 --- a/tests/components/aemet/test_weather.py +++ b/tests/components/aemet/test_weather.py @@ -29,7 +29,8 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, DOMAIN as WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + LEGACY_SERVICE_GET_FORECAST, + SERVICE_GET_FORECASTS, ) from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant @@ -54,25 +55,25 @@ async def test_aemet_weather( state = hass.states.get("weather.aemet") assert state assert state.state == ATTR_CONDITION_SNOWY - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_WEATHER_HUMIDITY) == 99.0 - assert state.attributes.get(ATTR_WEATHER_PRESSURE) == 1004.4 # 100440.0 Pa -> hPa - assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == -0.7 - assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 90.0 - assert state.attributes.get(ATTR_WEATHER_WIND_GUST_SPEED) == 24.0 - assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 15.0 # 4.17 m/s -> km/h - forecast = state.attributes.get(ATTR_FORECAST)[0] - assert forecast.get(ATTR_FORECAST_CONDITION) == ATTR_CONDITION_PARTLYCLOUDY - assert forecast.get(ATTR_FORECAST_PRECIPITATION) is None - assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 30 - assert forecast.get(ATTR_FORECAST_TEMP) == 4 - assert forecast.get(ATTR_FORECAST_TEMP_LOW) == -4 + assert state.attributes[ATTR_ATTRIBUTION] == ATTRIBUTION + assert state.attributes[ATTR_WEATHER_HUMIDITY] == 99.0 + assert state.attributes[ATTR_WEATHER_PRESSURE] == 1004.4 # 100440.0 Pa -> hPa + assert state.attributes[ATTR_WEATHER_TEMPERATURE] == -0.7 + assert state.attributes[ATTR_WEATHER_WIND_BEARING] == 122.0 + assert state.attributes[ATTR_WEATHER_WIND_GUST_SPEED] == 12.2 + assert state.attributes[ATTR_WEATHER_WIND_SPEED] == 3.2 + forecast = state.attributes[ATTR_FORECAST][0] + assert forecast[ATTR_FORECAST_CONDITION] == ATTR_CONDITION_PARTLYCLOUDY + assert ATTR_FORECAST_PRECIPITATION not in forecast + assert forecast[ATTR_FORECAST_PRECIPITATION_PROBABILITY] == 30 + assert forecast[ATTR_FORECAST_TEMP] == 4 + assert forecast[ATTR_FORECAST_TEMP_LOW] == -4 assert ( - forecast.get(ATTR_FORECAST_TIME) + forecast[ATTR_FORECAST_TIME] == dt_util.parse_datetime("2021-01-10 00:00:00+00:00").isoformat() ) - assert forecast.get(ATTR_FORECAST_WIND_BEARING) == 45.0 - assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 20.0 # 5.56 m/s -> km/h + assert forecast[ATTR_FORECAST_WIND_BEARING] == 45.0 + assert forecast[ATTR_FORECAST_WIND_SPEED] == 20.0 # 5.56 m/s -> km/h state = hass.states.get("weather.aemet_hourly") assert state is None @@ -81,11 +82,11 @@ async def test_aemet_weather( async def test_aemet_weather_legacy( hass: HomeAssistant, freezer: FrozenDateTimeFactory, + entity_registry: er.EntityRegistry, ) -> None: """Test states of legacy weather.""" - registry = er.async_get(hass) - registry.async_get_or_create( + entity_registry.async_get_or_create( WEATHER_DOMAIN, DOMAIN, "None hourly", @@ -98,34 +99,42 @@ async def test_aemet_weather_legacy( state = hass.states.get("weather.aemet_daily") assert state assert state.state == ATTR_CONDITION_SNOWY - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_WEATHER_HUMIDITY) == 99.0 - assert state.attributes.get(ATTR_WEATHER_PRESSURE) == 1004.4 # 100440.0 Pa -> hPa - assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == -0.7 - assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 90.0 - assert state.attributes.get(ATTR_WEATHER_WIND_GUST_SPEED) == 24.0 - assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 15.0 # 4.17 m/s -> km/h - forecast = state.attributes.get(ATTR_FORECAST)[0] - assert forecast.get(ATTR_FORECAST_CONDITION) == ATTR_CONDITION_PARTLYCLOUDY - assert forecast.get(ATTR_FORECAST_PRECIPITATION) is None - assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 30 - assert forecast.get(ATTR_FORECAST_TEMP) == 4 - assert forecast.get(ATTR_FORECAST_TEMP_LOW) == -4 + assert state.attributes[ATTR_ATTRIBUTION] == ATTRIBUTION + assert state.attributes[ATTR_WEATHER_HUMIDITY] == 99.0 + assert state.attributes[ATTR_WEATHER_PRESSURE] == 1004.4 # 100440.0 Pa -> hPa + assert state.attributes[ATTR_WEATHER_TEMPERATURE] == -0.7 + assert state.attributes[ATTR_WEATHER_WIND_BEARING] == 122.0 + assert state.attributes[ATTR_WEATHER_WIND_GUST_SPEED] == 12.2 + assert state.attributes[ATTR_WEATHER_WIND_SPEED] == 3.2 + forecast = state.attributes[ATTR_FORECAST][0] + assert forecast[ATTR_FORECAST_CONDITION] == ATTR_CONDITION_PARTLYCLOUDY + assert ATTR_FORECAST_PRECIPITATION not in forecast + assert forecast[ATTR_FORECAST_PRECIPITATION_PROBABILITY] == 30 + assert forecast[ATTR_FORECAST_TEMP] == 4 + assert forecast[ATTR_FORECAST_TEMP_LOW] == -4 assert ( - forecast.get(ATTR_FORECAST_TIME) + forecast[ATTR_FORECAST_TIME] == dt_util.parse_datetime("2021-01-10 00:00:00+00:00").isoformat() ) - assert forecast.get(ATTR_FORECAST_WIND_BEARING) == 45.0 - assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 20.0 # 5.56 m/s -> km/h + assert forecast[ATTR_FORECAST_WIND_BEARING] == 45.0 + assert forecast[ATTR_FORECAST_WIND_SPEED] == 20.0 # 5.56 m/s -> km/h state = hass.states.get("weather.aemet_hourly") assert state is None +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) async def test_forecast_service( hass: HomeAssistant, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, + service: str, ) -> None: """Test multiple forecast.""" @@ -135,7 +144,7 @@ async def test_forecast_service( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.aemet", "type": "daily", @@ -147,7 +156,7 @@ async def test_forecast_service( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.aemet", "type": "hourly", diff --git a/tests/components/airly/test_init.py b/tests/components/airly/test_init.py index 0a3ea927446..f24a75bbb6e 100644 --- a/tests/components/airly/test_init.py +++ b/tests/components/airly/test_init.py @@ -232,12 +232,12 @@ async def test_migrate_device_entry( async def test_remove_air_quality_entities( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test remove air_quality entities from registry.""" - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( AIR_QUALITY_PLATFORM, DOMAIN, "123-456", @@ -247,5 +247,5 @@ async def test_remove_air_quality_entities( await init_integration(hass, aioclient_mock) - entry = registry.async_get("air_quality.home") + entry = entity_registry.async_get("air_quality.home") assert entry is None diff --git a/tests/components/airly/test_sensor.py b/tests/components/airly/test_sensor.py index 4888176e175..35d7eb86c04 100644 --- a/tests/components/airly/test_sensor.py +++ b/tests/components/airly/test_sensor.py @@ -33,10 +33,13 @@ from tests.common import async_fire_time_changed, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker -async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def test_sensor( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, +) -> None: """Test states of the sensor.""" await init_integration(hass, aioclient_mock) - registry = er.async_get(hass) state = hass.states.get("sensor.home_common_air_quality_index") assert state @@ -45,7 +48,7 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "CAQI" assert state.attributes.get(ATTR_ICON) == "mdi:air-filter" - entry = registry.async_get("sensor.home_common_air_quality_index") + entry = entity_registry.async_get("sensor.home_common_air_quality_index") assert entry assert entry.unique_id == "123-456-caqi" assert entry.options["sensor"] == {"suggested_display_precision": 0} @@ -58,7 +61,7 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.HUMIDITY assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_humidity") + entry = entity_registry.async_get("sensor.home_humidity") assert entry assert entry.unique_id == "123-456-humidity" assert entry.options["sensor"] == {"suggested_display_precision": 1} @@ -74,7 +77,7 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM1 assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_pm1") + entry = entity_registry.async_get("sensor.home_pm1") assert entry assert entry.unique_id == "123-456-pm1" assert entry.options["sensor"] == {"suggested_display_precision": 0} @@ -90,7 +93,7 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM25 assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_pm2_5") + entry = entity_registry.async_get("sensor.home_pm2_5") assert entry assert entry.unique_id == "123-456-pm25" assert entry.options["sensor"] == {"suggested_display_precision": 0} @@ -106,7 +109,7 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM10 assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_pm10") + entry = entity_registry.async_get("sensor.home_pm10") assert entry assert entry.unique_id == "123-456-pm10" assert entry.options["sensor"] == {"suggested_display_precision": 0} @@ -122,7 +125,7 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert entry.options["sensor"] == {"suggested_display_precision": 0} - entry = registry.async_get("sensor.home_carbon_monoxide") + entry = entity_registry.async_get("sensor.home_carbon_monoxide") assert entry assert entry.unique_id == "123-456-co" @@ -137,7 +140,7 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.NITROGEN_DIOXIDE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_nitrogen_dioxide") + entry = entity_registry.async_get("sensor.home_nitrogen_dioxide") assert entry assert entry.unique_id == "123-456-no2" assert entry.options["sensor"] == {"suggested_display_precision": 0} @@ -153,7 +156,7 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.OZONE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_ozone") + entry = entity_registry.async_get("sensor.home_ozone") assert entry assert entry.unique_id == "123-456-o3" assert entry.options["sensor"] == {"suggested_display_precision": 0} @@ -169,7 +172,7 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.SULPHUR_DIOXIDE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_sulphur_dioxide") + entry = entity_registry.async_get("sensor.home_sulphur_dioxide") assert entry assert entry.unique_id == "123-456-so2" assert entry.options["sensor"] == {"suggested_display_precision": 0} @@ -182,7 +185,7 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PRESSURE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_pressure") + entry = entity_registry.async_get("sensor.home_pressure") assert entry assert entry.unique_id == "123-456-pressure" assert entry.options["sensor"] == {"suggested_display_precision": 0} @@ -195,7 +198,7 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_temperature") + entry = entity_registry.async_get("sensor.home_temperature") assert entry assert entry.unique_id == "123-456-temperature" assert entry.options["sensor"] == {"suggested_display_precision": 1} diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index 252c12f80fa..52fc8d2300b 100644 --- a/tests/components/airq/test_config_flow.py +++ b/tests/components/airq/test_config_flow.py @@ -1,7 +1,7 @@ """Test the air-Q config flow.""" from unittest.mock import patch -from aioairq.core import DeviceInfo, InvalidAuth, InvalidInput +from aioairq import DeviceInfo, InvalidAuth, InvalidInput from aiohttp.client_exceptions import ClientConnectionError import pytest diff --git a/tests/components/airvisual/test_init.py b/tests/components/airvisual/test_init.py index 7515ad832ce..4f71e75da1e 100644 --- a/tests/components/airvisual/test_init.py +++ b/tests/components/airvisual/test_init.py @@ -99,7 +99,9 @@ async def test_migration_1_2(hass: HomeAssistant, mock_pyairvisual) -> None: } -async def test_migration_2_3(hass: HomeAssistant, mock_pyairvisual) -> None: +async def test_migration_2_3( + hass: HomeAssistant, mock_pyairvisual, device_registry: dr.DeviceRegistry +) -> None: """Test migrating from version 2 to 3.""" entry = MockConfigEntry( domain=DOMAIN, @@ -113,7 +115,6 @@ async def test_migration_2_3(hass: HomeAssistant, mock_pyairvisual) -> None: ) entry.add_to_hass(hass) - device_registry = dr.async_get(hass) device_registry.async_get_or_create( name="192.168.1.100", config_entry_id=entry.entry_id, diff --git a/tests/components/airvisual_pro/conftest.py b/tests/components/airvisual_pro/conftest.py index 4376db23366..9ebe13c83e6 100644 --- a/tests/components/airvisual_pro/conftest.py +++ b/tests/components/airvisual_pro/conftest.py @@ -78,9 +78,7 @@ async def setup_airvisual_pro_fixture(hass, config, pro): "homeassistant.components.airvisual_pro.config_flow.NodeSamba", return_value=pro ), patch( "homeassistant.components.airvisual_pro.NodeSamba", return_value=pro - ), patch( - "homeassistant.components.airvisual.PLATFORMS", [] - ): + ), patch("homeassistant.components.airvisual.PLATFORMS", []): assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() yield diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index b9ab7198148..9cb6e550711 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -229,6 +229,7 @@ 'mac': '**REDACTED**', 'wifi_channel': 6, 'wifi_rssi': -42, + 'ws_type': 'ws_az', }), }), 'config_entry': dict({ @@ -323,7 +324,9 @@ }), 'version': '1.62', 'webserver': dict({ + 'full-name': 'Airzone WebServer', 'mac': '**REDACTED**', + 'model': 'Airzone WebServer', 'wifi-channel': 6, 'wifi-rssi': -42, }), diff --git a/tests/components/airzone/test_binary_sensor.py b/tests/components/airzone/test_binary_sensor.py index 8033871f5c3..a620a3338c2 100644 --- a/tests/components/airzone/test_binary_sensor.py +++ b/tests/components/airzone/test_binary_sensor.py @@ -21,7 +21,7 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.despacho_air_demand") assert state.state == STATE_OFF - state = hass.states.get("binary_sensor.despacho_battery_low") + state = hass.states.get("binary_sensor.despacho_battery") assert state.state == STATE_ON state = hass.states.get("binary_sensor.despacho_floor_demand") @@ -34,7 +34,7 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.dorm_1_air_demand") assert state.state == STATE_OFF - state = hass.states.get("binary_sensor.dorm_1_battery_low") + state = hass.states.get("binary_sensor.dorm_1_battery") assert state.state == STATE_OFF state = hass.states.get("binary_sensor.dorm_1_floor_demand") @@ -46,7 +46,7 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.dorm_2_air_demand") assert state.state == STATE_OFF - state = hass.states.get("binary_sensor.dorm_2_battery_low") + state = hass.states.get("binary_sensor.dorm_2_battery") assert state.state == STATE_OFF state = hass.states.get("binary_sensor.dorm_2_floor_demand") @@ -58,7 +58,7 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.dorm_ppal_air_demand") assert state.state == STATE_ON - state = hass.states.get("binary_sensor.dorm_ppal_battery_low") + state = hass.states.get("binary_sensor.dorm_ppal_battery") assert state.state == STATE_OFF state = hass.states.get("binary_sensor.dorm_ppal_floor_demand") @@ -70,7 +70,7 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.salon_air_demand") assert state.state == STATE_OFF - state = hass.states.get("binary_sensor.salon_battery_low") + state = hass.states.get("binary_sensor.salon_battery") assert state is None state = hass.states.get("binary_sensor.salon_floor_demand") @@ -79,13 +79,13 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.salon_problem") assert state.state == STATE_OFF - state = hass.states.get("binary_sensor.airzone_2_1_battery_low") + state = hass.states.get("binary_sensor.airzone_2_1_battery") assert state is None state = hass.states.get("binary_sensor.airzone_2_1_problem") assert state.state == STATE_OFF - state = hass.states.get("binary_sensor.dkn_plus_battery_low") + state = hass.states.get("binary_sensor.dkn_plus_battery") assert state is None state = hass.states.get("binary_sensor.dkn_plus_problem") diff --git a/tests/components/airzone/test_climate.py b/tests/components/airzone/test_climate.py index 94bea0a5e07..34844e34370 100644 --- a/tests/components/airzone/test_climate.py +++ b/tests/components/airzone/test_climate.py @@ -536,6 +536,7 @@ async def test_airzone_climate_set_temp(hass: HomeAssistant) -> None: API_SYSTEM_ID: 1, API_ZONE_ID: 5, API_SET_POINT: 20.5, + API_ON: 1, } ] } @@ -551,12 +552,14 @@ async def test_airzone_climate_set_temp(hass: HomeAssistant) -> None: SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: "climate.dorm_2", + ATTR_HVAC_MODE: HVACMode.HEAT, ATTR_TEMPERATURE: 20.5, }, blocking=True, ) state = hass.states.get("climate.dorm_2") + assert state.state == HVACMode.HEAT assert state.attributes.get(ATTR_TEMPERATURE) == 20.5 diff --git a/tests/components/airzone/test_init.py b/tests/components/airzone/test_init.py index 2214e5d07ab..8936fa3e282 100644 --- a/tests/components/airzone/test_init.py +++ b/tests/components/airzone/test_init.py @@ -14,11 +14,11 @@ from .util import CONFIG, HVAC_MOCK, HVAC_VERSION_MOCK, HVAC_WEBSERVER_MOCK from tests.common import MockConfigEntry -async def test_unique_id_migrate(hass: HomeAssistant) -> None: +async def test_unique_id_migrate( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test unique id migration.""" - entity_registry = er.async_get(hass) - config_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG) config_entry.add_to_hass(hass) diff --git a/tests/components/airzone/test_sensor.py b/tests/components/airzone/test_sensor.py index 6d94defa004..1511cd4362c 100644 --- a/tests/components/airzone/test_sensor.py +++ b/tests/components/airzone/test_sensor.py @@ -34,7 +34,7 @@ async def test_airzone_create_sensors( assert state.state == "43" # WebServer - state = hass.states.get("sensor.webserver_rssi") + state = hass.states.get("sensor.airzone_webserver_rssi") assert state.state == "-42" # Zones diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index eb687731eb7..a3454549e05 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -50,6 +50,8 @@ from aioairzone.const import ( API_VERSION, API_WIFI_CHANNEL, API_WIFI_RSSI, + API_WS_AZ, + API_WS_TYPE, API_ZONE_ID, ) @@ -301,6 +303,7 @@ HVAC_VERSION_MOCK = { HVAC_WEBSERVER_MOCK = { API_MAC: "11:22:33:44:55:66", + API_WS_TYPE: API_WS_AZ, API_WIFI_CHANNEL: 6, API_WIFI_RSSI: -42, } diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index 1d1d060e80a..594a5e6765a 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -5,10 +5,18 @@ 'devices-config': dict({ 'device1': dict({ }), + 'device2': dict({ + }), + 'device3': dict({ + }), }), 'devices-status': dict({ 'device1': dict({ }), + 'device2': dict({ + }), + 'device3': dict({ + }), }), 'installations': dict({ 'installation1': dict({ @@ -22,11 +30,31 @@ ]), 'group_id': 'group1', }), + dict({ + 'devices': list([ + dict({ + 'device_id': 'device2', + 'ws_id': 'webserver2', + }), + ]), + 'group_id': 'group2', + }), + dict({ + 'devices': list([ + dict({ + 'device_id': 'device3', + 'ws_id': 'webserver3', + }), + ]), + 'group_id': 'group3', + }), ]), 'plugins': dict({ 'schedules': dict({ 'calendar_ws_ids': list([ 'webserver1', + 'webserver2', + 'webserver3', ]), }), }), @@ -50,6 +78,10 @@ 'webservers': dict({ 'webserver1': dict({ }), + 'webserver2': dict({ + }), + 'webserver3': dict({ + }), }), }), 'config_entry': dict({ @@ -90,6 +122,13 @@ 'name': 'Bron', 'power': False, 'problems': False, + 'speed': 6, + 'speed-type': 0, + 'speeds': dict({ + '1': 2, + '2': 4, + '3': 6, + }), 'temperature': 21.0, 'temperature-setpoint': 22.0, 'temperature-setpoint-cool-air': 22.0, @@ -103,7 +142,51 @@ 'temperature-setpoint-min-cool-air': 18.0, 'temperature-setpoint-min-hot-air': 16.0, 'temperature-step': 0.5, - 'web-server': '11:22:33:44:55:67', + 'web-server': 'webserver2', + 'ws-connected': True, + }), + 'aidoo_pro': dict({ + 'action': 1, + 'active': True, + 'available': True, + 'id': 'aidoo_pro', + 'installation': 'installation1', + 'is-connected': True, + 'mode': 2, + 'modes': list([ + 1, + 2, + 3, + 4, + 5, + ]), + 'name': 'Bron Pro', + 'power': True, + 'problems': False, + 'speed': 3, + 'speed-type': 0, + 'speeds': dict({ + '0': 0, + '1': 1, + '2': 2, + '3': 3, + '4': 4, + '5': 5, + }), + 'temperature': 20.0, + 'temperature-setpoint': 22.0, + 'temperature-setpoint-cool-air': 22.0, + 'temperature-setpoint-hot-air': 22.0, + 'temperature-setpoint-max': 30.0, + 'temperature-setpoint-max-auto-air': 30.0, + 'temperature-setpoint-max-cool-air': 30.0, + 'temperature-setpoint-max-hot-air': 30.0, + 'temperature-setpoint-min': 15.0, + 'temperature-setpoint-min-auto-air': 18.0, + 'temperature-setpoint-min-cool-air': 18.0, + 'temperature-setpoint-min-hot-air': 16.0, + 'temperature-step': 0.5, + 'web-server': 'webserver3', 'ws-connected': True, }), }), @@ -138,14 +221,14 @@ 'zone2', ]), }), - 'grp2': dict({ + 'group2': dict({ 'action': 6, 'active': False, 'aidoos': list([ 'aidoo1', ]), 'available': True, - 'id': 'grp2', + 'id': 'group2', 'installation': 'installation1', 'mode': 3, 'modes': list([ @@ -164,6 +247,32 @@ 'temperature-setpoint-min': 15.0, 'temperature-step': 0.5, }), + 'group3': dict({ + 'action': 1, + 'active': True, + 'aidoos': list([ + 'aidoo_pro', + ]), + 'available': True, + 'id': 'group3', + 'installation': 'installation1', + 'mode': 2, + 'modes': list([ + 1, + 2, + 3, + 4, + 5, + ]), + 'name': 'Aidoo Pro Group', + 'num-devices': 1, + 'power': True, + 'temperature': 20.0, + 'temperature-setpoint': 22.0, + 'temperature-setpoint-max': 30.0, + 'temperature-setpoint-min': 15.0, + 'temperature-step': 0.5, + }), }), 'installations': dict({ 'installation1': dict({ @@ -171,11 +280,13 @@ 'active': True, 'aidoos': list([ 'aidoo1', + 'aidoo_pro', ]), 'available': True, 'groups': list([ 'group1', - 'grp2', + 'group2', + 'group3', ]), 'humidity': 27, 'id': 'installation1', @@ -188,20 +299,21 @@ 5, ]), 'name': 'House', - 'num-devices': 3, - 'num-groups': 2, + 'num-devices': 4, + 'num-groups': 3, 'power': True, 'systems': list([ 'system1', ]), - 'temperature': 22.0, - 'temperature-setpoint': 23.3, + 'temperature': 21.5, + 'temperature-setpoint': 23.0, 'temperature-setpoint-max': 30.0, 'temperature-setpoint-min': 15.0, 'temperature-step': 0.5, 'web-servers': list([ 'webserver1', - '11:22:33:44:55:67', + 'webserver2', + 'webserver3', ]), 'zones': list([ 'zone1', @@ -235,21 +347,6 @@ }), }), 'web-servers': dict({ - '11:22:33:44:55:67': dict({ - 'available': True, - 'connection-date': '2023-05-24 17:00:52 +0200', - 'disconnection-date': '2023-05-24 17:00:25 +0200', - 'firmware': '3.13', - 'id': '11:22:33:44:55:67', - 'installation': 'installation1', - 'name': 'WebServer 11:22:33:44:55:67', - 'type': 'ws_aidoo', - 'wifi-channel': 1, - 'wifi-mac': '**REDACTED**', - 'wifi-quality': 4, - 'wifi-rssi': -77, - 'wifi-ssid': 'Wifi', - }), 'webserver1': dict({ 'available': True, 'connection-date': '2023-05-07T12:55:51.000Z', @@ -265,6 +362,36 @@ 'wifi-rssi': -56, 'wifi-ssid': 'Wifi', }), + 'webserver2': dict({ + 'available': True, + 'connection-date': '2023-05-24 17:00:52 +0200', + 'disconnection-date': '2023-05-24 17:00:25 +0200', + 'firmware': '3.13', + 'id': 'webserver2', + 'installation': 'installation1', + 'name': 'WebServer 11:22:33:44:55:67', + 'type': 'ws_aidoo', + 'wifi-channel': 1, + 'wifi-mac': '**REDACTED**', + 'wifi-quality': 4, + 'wifi-rssi': -77, + 'wifi-ssid': 'Wifi', + }), + 'webserver3': dict({ + 'available': True, + 'connection-date': '2023-11-05 17:00:52 +0200', + 'disconnection-date': '2023-11-05 17:00:25 +0200', + 'firmware': '4.01', + 'id': 'webserver3', + 'installation': 'installation1', + 'name': 'WebServer 11:22:33:44:55:68', + 'type': 'ws_aidoo', + 'wifi-channel': 6, + 'wifi-mac': '**REDACTED**', + 'wifi-quality': 4, + 'wifi-rssi': -67, + 'wifi-ssid': 'Wifi', + }), }), 'zones': dict({ 'zone1': dict({ diff --git a/tests/components/airzone_cloud/test_binary_sensor.py b/tests/components/airzone_cloud/test_binary_sensor.py index a1b5d5319c0..ca40a732046 100644 --- a/tests/components/airzone_cloud/test_binary_sensor.py +++ b/tests/components/airzone_cloud/test_binary_sensor.py @@ -22,6 +22,14 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.bron_running") assert state.state == STATE_OFF + state = hass.states.get("binary_sensor.bron_pro_problem") + assert state.state == STATE_OFF + assert state.attributes.get("errors") is None + assert state.attributes.get("warnings") is None + + state = hass.states.get("binary_sensor.bron_pro_running") + assert state.state == STATE_ON + # Systems state = hass.states.get("binary_sensor.system_1_problem") assert state.state == STATE_ON diff --git a/tests/components/airzone_cloud/test_climate.py b/tests/components/airzone_cloud/test_climate.py index 4106b1af1e9..7c273dc8bc2 100644 --- a/tests/components/airzone_cloud/test_climate.py +++ b/tests/components/airzone_cloud/test_climate.py @@ -40,7 +40,7 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: # Aidoos state = hass.states.get("climate.bron") assert state.state == HVACMode.OFF - assert state.attributes.get(ATTR_CURRENT_HUMIDITY) is None + assert ATTR_CURRENT_HUMIDITY not in state.attributes assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.0 assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF assert state.attributes[ATTR_HVAC_MODES] == [ @@ -56,6 +56,24 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.attributes[ATTR_TARGET_TEMP_STEP] == API_TEMPERATURE_STEP assert state.attributes[ATTR_TEMPERATURE] == 22.0 + state = hass.states.get("climate.bron_pro") + assert state.state == HVACMode.COOL + assert ATTR_CURRENT_HUMIDITY not in state.attributes + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.0 + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + assert state.attributes[ATTR_HVAC_MODES] == [ + HVACMode.HEAT_COOL, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + HVACMode.OFF, + ] + assert state.attributes[ATTR_MAX_TEMP] == 30 + assert state.attributes[ATTR_MIN_TEMP] == 15 + assert state.attributes[ATTR_TARGET_TEMP_STEP] == API_TEMPERATURE_STEP + assert state.attributes[ATTR_TEMPERATURE] == 22.0 + # Groups state = hass.states.get("climate.group") assert state.state == HVACMode.COOL @@ -78,7 +96,7 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: state = hass.states.get("climate.house") assert state.state == HVACMode.COOL assert state.attributes[ATTR_CURRENT_HUMIDITY] == 27 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 22.0 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.5 assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING assert state.attributes[ATTR_HVAC_MODES] == [ HVACMode.HEAT_COOL, @@ -91,7 +109,7 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.attributes[ATTR_MAX_TEMP] == 30 assert state.attributes[ATTR_MIN_TEMP] == 15 assert state.attributes[ATTR_TARGET_TEMP_STEP] == API_TEMPERATURE_STEP - assert state.attributes[ATTR_TEMPERATURE] == 23.3 + assert state.attributes[ATTR_TEMPERATURE] == 23.0 # Zones state = hass.states.get("climate.dormitorio") @@ -453,12 +471,14 @@ async def test_airzone_climate_set_temp(hass: HomeAssistant) -> None: SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: "climate.house", + ATTR_HVAC_MODE: HVACMode.HEAT, ATTR_TEMPERATURE: 20.5, }, blocking=True, ) state = hass.states.get("climate.house") + assert state.state == HVACMode.HEAT assert state.attributes[ATTR_TEMPERATURE] == 20.5 # Zones @@ -471,12 +491,14 @@ async def test_airzone_climate_set_temp(hass: HomeAssistant) -> None: SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: "climate.salon", + ATTR_HVAC_MODE: HVACMode.HEAT, ATTR_TEMPERATURE: 20.5, }, blocking=True, ) state = hass.states.get("climate.salon") + assert state.state == HVACMode.HEAT assert state.attributes[ATTR_TEMPERATURE] == 20.5 @@ -537,7 +559,7 @@ async def test_airzone_climate_set_temp_error(hass: HomeAssistant) -> None: ) state = hass.states.get("climate.house") - assert state.attributes[ATTR_TEMPERATURE] == 23.3 + assert state.attributes[ATTR_TEMPERATURE] == 23.0 # Zones with patch( diff --git a/tests/components/airzone_cloud/test_diagnostics.py b/tests/components/airzone_cloud/test_diagnostics.py index 8bef70501e7..2b2e3f33105 100644 --- a/tests/components/airzone_cloud/test_diagnostics.py +++ b/tests/components/airzone_cloud/test_diagnostics.py @@ -20,7 +20,7 @@ from homeassistant.components.airzone_cloud.const import DOMAIN from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant -from .util import CONFIG, WS_ID, async_init_integration +from .util import CONFIG, WS_ID, WS_ID_AIDOO, WS_ID_AIDOO_PRO, async_init_integration from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -28,9 +28,13 @@ from tests.typing import ClientSessionGenerator RAW_DATA_MOCK = { RAW_DEVICES_CONFIG: { "dev1": {}, + "dev2": {}, + "dev3": {}, }, RAW_DEVICES_STATUS: { "dev1": {}, + "dev2": {}, + "dev3": {}, }, RAW_INSTALLATIONS: { CONFIG[CONF_ID]: { @@ -44,11 +48,31 @@ RAW_DATA_MOCK = { }, ], }, + { + API_GROUP_ID: "grp2", + API_DEVICES: [ + { + API_DEVICE_ID: "dev2", + API_WS_ID: WS_ID_AIDOO, + }, + ], + }, + { + API_GROUP_ID: "grp3", + API_DEVICES: [ + { + API_DEVICE_ID: "dev3", + API_WS_ID: WS_ID_AIDOO_PRO, + }, + ], + }, ], "plugins": { "schedules": { "calendar_ws_ids": [ WS_ID, + WS_ID_AIDOO, + WS_ID_AIDOO_PRO, ], }, }, @@ -57,6 +81,8 @@ RAW_DATA_MOCK = { RAW_INSTALLATIONS_LIST: {}, RAW_WEBSERVERS: { WS_ID: {}, + WS_ID_AIDOO: {}, + WS_ID_AIDOO_PRO: {}, }, "test_cov": { "1": None, diff --git a/tests/components/airzone_cloud/test_init.py b/tests/components/airzone_cloud/test_init.py index 3a6497fdeba..f8a7a710e08 100644 --- a/tests/components/airzone_cloud/test_init.py +++ b/tests/components/airzone_cloud/test_init.py @@ -24,6 +24,9 @@ async def test_unload_entry(hass: HomeAssistant) -> None: with patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", return_value=None, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.logout", + return_value=None, ), patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.list_installations", return_value=[], diff --git a/tests/components/airzone_cloud/test_sensor.py b/tests/components/airzone_cloud/test_sensor.py index d9b19f93f7d..b370e75c9aa 100644 --- a/tests/components/airzone_cloud/test_sensor.py +++ b/tests/components/airzone_cloud/test_sensor.py @@ -16,6 +16,9 @@ async def test_airzone_create_sensors( state = hass.states.get("sensor.bron_temperature") assert state.state == "21.0" + state = hass.states.get("sensor.bron_pro_temperature") + assert state.state == "20.0" + # WebServers state = hass.states.get("sensor.webserver_11_22_33_44_55_66_signal_strength") assert state.state == "-56" diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index 76349d06481..6924344a092 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -7,6 +7,7 @@ from aioairzone_cloud.common import OperationMode from aioairzone_cloud.const import ( API_ACTIVE, API_AZ_AIDOO, + API_AZ_AIDOO_PRO, API_AZ_SYSTEM, API_AZ_ZONE, API_CELSIUS, @@ -52,6 +53,9 @@ from aioairzone_cloud.const import ( API_SP_AIR_HEAT, API_SP_AIR_STOP, API_SP_AIR_VENT, + API_SPEED_CONF, + API_SPEED_TYPE, + API_SPEED_VALUES, API_STAT_AP_MAC, API_STAT_CHANNEL, API_STAT_QUALITY, @@ -79,6 +83,7 @@ from tests.common import MockConfigEntry WS_ID = "11:22:33:44:55:66" WS_ID_AIDOO = "11:22:33:44:55:67" +WS_ID_AIDOO_PRO = "11:22:33:44:55:68" CONFIG = { CONF_ID: "inst1", @@ -136,6 +141,18 @@ GET_INSTALLATION_MOCK = { }, ], }, + { + API_GROUP_ID: "grp3", + API_NAME: "Aidoo Pro Group", + API_DEVICES: [ + { + API_DEVICE_ID: "aidoo_pro", + API_NAME: "Bron Pro", + API_TYPE: API_AZ_AIDOO_PRO, + API_WS_ID: WS_ID_AIDOO_PRO, + }, + ], + }, ], } @@ -147,6 +164,7 @@ GET_INSTALLATIONS_MOCK = { API_WS_IDS: [ WS_ID, WS_ID_AIDOO, + WS_ID_AIDOO_PRO, ], }, ], @@ -186,6 +204,23 @@ GET_WEBSERVER_MOCK_AIDOO = { }, } +GET_WEBSERVER_MOCK_AIDOO_PRO = { + API_WS_TYPE: "ws_aidoo", + API_CONFIG: { + API_WS_FW: "4.01", + API_STAT_SSID: "Wifi", + API_STAT_CHANNEL: 6, + API_STAT_AP_MAC: "00:00:00:00:00:02", + }, + API_STATUS: { + API_IS_CONNECTED: True, + API_STAT_QUALITY: 4, + API_STAT_RSSI: -67, + API_CONNECTION_DATE: "2023-11-05 17:00:52 +0200", + API_DISCONNECTION_DATE: "2023-11-05 17:00:25 +0200", + }, +} + def mock_get_device_status(device: Device) -> dict[str, Any]: """Mock API device status.""" @@ -214,11 +249,46 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: API_RANGE_SP_MIN_COOL_AIR: {API_CELSIUS: 18, API_FAH: 64}, API_RANGE_SP_MIN_HOT_AIR: {API_CELSIUS: 16, API_FAH: 61}, API_POWER: False, + API_SPEED_CONF: 6, + API_SPEED_VALUES: [2, 4, 6], + API_SPEED_TYPE: 0, API_IS_CONNECTED: True, API_WS_CONNECTED: True, API_LOCAL_TEMP: {API_CELSIUS: 21, API_FAH: 70}, API_WARNINGS: [], } + if device.get_id() == "aidoo_pro": + return { + API_ACTIVE: True, + API_ERRORS: [], + API_MODE: OperationMode.COOLING.value, + API_MODE_AVAIL: [ + OperationMode.AUTO.value, + OperationMode.COOLING.value, + OperationMode.HEATING.value, + OperationMode.VENTILATION.value, + OperationMode.DRY.value, + ], + API_SP_AIR_AUTO: {API_CELSIUS: 22, API_FAH: 72}, + API_SP_AIR_COOL: {API_CELSIUS: 22, API_FAH: 72}, + API_SP_AIR_HEAT: {API_CELSIUS: 22, API_FAH: 72}, + API_RANGE_MAX_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_AUTO_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_COOL_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_HOT_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_MIN_AIR: {API_CELSIUS: 15, API_FAH: 59}, + API_RANGE_SP_MIN_AUTO_AIR: {API_CELSIUS: 18, API_FAH: 64}, + API_RANGE_SP_MIN_COOL_AIR: {API_CELSIUS: 18, API_FAH: 64}, + API_RANGE_SP_MIN_HOT_AIR: {API_CELSIUS: 16, API_FAH: 61}, + API_POWER: True, + API_SPEED_CONF: 3, + API_SPEED_VALUES: [0, 1, 2, 3, 4, 5], + API_SPEED_TYPE: 0, + API_IS_CONNECTED: True, + API_WS_CONNECTED: True, + API_LOCAL_TEMP: {API_CELSIUS: 20, API_FAH: 68}, + API_WARNINGS: [], + } if device.get_id() == "system1": return { API_ERRORS: [ @@ -304,16 +374,19 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: API_LOCAL_TEMP: {API_FAH: 77, API_CELSIUS: 25}, API_WARNINGS: [], } - return None + return {} def mock_get_webserver(webserver: WebServer, devices: bool) -> dict[str, Any]: """Mock API get webserver.""" + if webserver.get_id() == WS_ID: + return GET_WEBSERVER_MOCK if webserver.get_id() == WS_ID_AIDOO: return GET_WEBSERVER_MOCK_AIDOO - - return GET_WEBSERVER_MOCK + if webserver.get_id() == WS_ID_AIDOO_PRO: + return GET_WEBSERVER_MOCK_AIDOO_PRO + return {} async def async_init_integration( diff --git a/tests/components/alexa/test_entities.py b/tests/components/alexa/test_entities.py index 3fb79c86e50..87aab24a3b1 100644 --- a/tests/components/alexa/test_entities.py +++ b/tests/components/alexa/test_entities.py @@ -25,9 +25,10 @@ async def test_unsupported_domain(hass: HomeAssistant) -> None: assert not msg["payload"]["endpoints"] -async def test_categorized_hidden_entities(hass: HomeAssistant) -> None: +async def test_categorized_hidden_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Discovery ignores hidden and categorized entities.""" - entity_registry = er.async_get(hass) request = get_new_request("Alexa.Discovery", "Discover") entity_entry1 = entity_registry.async_get_or_create( diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 4e51880c754..d22738a7e6b 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -180,9 +180,11 @@ async def test_send_base_with_supervisor( "homeassistant.components.hassio.is_hassio", side_effect=Mock(return_value=True), ), patch( - "uuid.UUID.hex", new_callable=PropertyMock + "uuid.UUID.hex", + new_callable=PropertyMock, ) as hex, patch( - "homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION + "homeassistant.components.analytics.analytics.HA_VERSION", + MOCK_VERSION, ): hex.return_value = MOCK_UUID await analytics.load() @@ -289,7 +291,8 @@ async def test_send_usage_with_supervisor( "homeassistant.components.hassio.is_hassio", side_effect=Mock(return_value=True), ), patch( - "homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION + "homeassistant.components.analytics.analytics.HA_VERSION", + MOCK_VERSION, ): await analytics.send_analytics() assert ( @@ -492,7 +495,8 @@ async def test_send_statistics_with_supervisor( "homeassistant.components.hassio.is_hassio", side_effect=Mock(return_value=True), ), patch( - "homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION + "homeassistant.components.analytics.analytics.HA_VERSION", + MOCK_VERSION, ): await analytics.send_analytics() assert "'addon_count': 1" in caplog.text diff --git a/tests/components/anova/__init__.py b/tests/components/anova/__init__.py index 5bcb84cb974..aa58ee5bbb5 100644 --- a/tests/components/anova/__init__.py +++ b/tests/components/anova/__init__.py @@ -51,7 +51,7 @@ async def async_init_integration( ) as update_patch, patch( "homeassistant.components.anova.AnovaApi.authenticate" ), patch( - "homeassistant.components.anova.AnovaApi.get_devices" + "homeassistant.components.anova.AnovaApi.get_devices", ) as device_patch: update_patch.return_value = ONLINE_UPDATE device_patch.return_value = [ diff --git a/tests/components/apcupsd/__init__.py b/tests/components/apcupsd/__init__.py index b8a83f950d0..b0eee051331 100644 --- a/tests/components/apcupsd/__init__.py +++ b/tests/components/apcupsd/__init__.py @@ -95,8 +95,9 @@ async def async_init_integration( entry.add_to_hass(hass) - with patch("apcaccess.status.parse", return_value=status), patch( - "apcaccess.status.get", return_value=b"" + with ( + patch("apcaccess.status.parse", return_value=status), + patch("apcaccess.status.get", return_value=b""), ): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/apcupsd/test_binary_sensor.py b/tests/components/apcupsd/test_binary_sensor.py index 6ba9a09f837..033b1ff6b82 100644 --- a/tests/components/apcupsd/test_binary_sensor.py +++ b/tests/components/apcupsd/test_binary_sensor.py @@ -5,15 +5,16 @@ from homeassistant.helpers import entity_registry as er from . import MOCK_STATUS, async_init_integration -async def test_binary_sensor(hass: HomeAssistant) -> None: +async def test_binary_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test states of binary sensor.""" await async_init_integration(hass, status=MOCK_STATUS) - registry = er.async_get(hass) state = hass.states.get("binary_sensor.ups_online_status") assert state assert state.state == "on" - entry = registry.async_get("binary_sensor.ups_online_status") + entry = entity_registry.async_get("binary_sensor.ups_online_status") assert entry assert entry.unique_id == "XXXXXXXXXXXX_statflag" diff --git a/tests/components/apcupsd/test_config_flow.py b/tests/components/apcupsd/test_config_flow.py index 6ac7992f404..48d57890320 100644 --- a/tests/components/apcupsd/test_config_flow.py +++ b/tests/components/apcupsd/test_config_flow.py @@ -38,10 +38,10 @@ async def test_config_flow_cannot_connect(hass: HomeAssistant) -> None: async def test_config_flow_no_status(hass: HomeAssistant) -> None: """Test config flow setup with successful connection but no status is reported.""" - with patch( - "apcaccess.status.parse", - return_value={}, # Returns no status. - ), patch("apcaccess.status.get", return_value=b""): + with ( + patch("apcaccess.status.parse", return_value={}), # Returns no status. + patch("apcaccess.status.get", return_value=b""), + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -63,9 +63,11 @@ async def test_config_flow_duplicate(hass: HomeAssistant) -> None: ) mock_entry.add_to_hass(hass) - with patch("apcaccess.status.parse") as mock_parse, patch( - "apcaccess.status.get", return_value=b"" - ), _patch_setup(): + with ( + patch("apcaccess.status.parse") as mock_parse, + patch("apcaccess.status.get", return_value=b""), + _patch_setup(), + ): mock_parse.return_value = MOCK_STATUS # Now, create the integration again using the same config data, we should reject @@ -109,9 +111,11 @@ async def test_config_flow_duplicate(hass: HomeAssistant) -> None: async def test_flow_works(hass: HomeAssistant) -> None: """Test successful creation of config entries via user configuration.""" - with patch("apcaccess.status.parse", return_value=MOCK_STATUS), patch( - "apcaccess.status.get", return_value=b"" - ), _patch_setup() as mock_setup: + with ( + patch("apcaccess.status.parse", return_value=MOCK_STATUS), + patch("apcaccess.status.get", return_value=b""), + _patch_setup() as mock_setup, + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, @@ -147,9 +151,11 @@ async def test_flow_minimal_status( We test different combinations of minimal statuses, where the title of the integration will vary. """ - with patch("apcaccess.status.parse") as mock_parse, patch( - "apcaccess.status.get", return_value=b"" - ), _patch_setup() as mock_setup: + with ( + patch("apcaccess.status.parse") as mock_parse, + patch("apcaccess.status.get", return_value=b""), + _patch_setup() as mock_setup, + ): status = MOCK_MINIMAL_STATUS | extra_status mock_parse.return_value = status diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py index 8c29edabbc1..756fa07f120 100644 --- a/tests/components/apcupsd/test_init.py +++ b/tests/components/apcupsd/test_init.py @@ -4,15 +4,17 @@ from unittest.mock import patch import pytest -from homeassistant.components.apcupsd import DOMAIN +from homeassistant.components.apcupsd.const import DOMAIN +from homeassistant.components.apcupsd.coordinator import UPDATE_INTERVAL from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.util import utcnow from . import CONF_DATA, MOCK_MINIMAL_STATUS, MOCK_STATUS, async_init_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.parametrize("status", (MOCK_STATUS, MOCK_MINIMAL_STATUS)) @@ -42,19 +44,19 @@ async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> No MOCK_STATUS, ), ) -async def test_device_entry(hass: HomeAssistant, status: OrderedDict) -> None: +async def test_device_entry( + hass: HomeAssistant, status: OrderedDict, device_registry: dr.DeviceRegistry +) -> None: """Test successful setup of device entries.""" await async_init_integration(hass, status=status) # Verify device info is properly set up. - device_entries = dr.async_get(hass) - if "SERIALNO" not in status: - assert len(device_entries.devices) == 0 + assert len(device_registry.devices) == 0 return - assert len(device_entries.devices) == 1 - entry = device_entries.async_get_device({(DOMAIN, status["SERIALNO"])}) + assert len(device_registry.devices) == 1 + entry = device_registry.async_get_device({(DOMAIN, status["SERIALNO"])}) assert entry is not None # Specify the mapping between field name and the expected fields in device entry. fields = { @@ -67,11 +69,11 @@ async def test_device_entry(hass: HomeAssistant, status: OrderedDict) -> None: for field, entry_value in fields.items(): if field in status: assert entry_value == status[field] + # Even if UPSNAME is not available, we must fall back to default "APC UPS". elif field == "UPSNAME": - # Even if UPSNAME is not available, we must fall back to default "APC UPS". assert entry_value == "APC UPS" else: - assert entry_value is None + assert not entry_value assert entry.manufacturer == "APC" @@ -107,15 +109,16 @@ async def test_connection_error(hass: HomeAssistant) -> None: entry.add_to_hass(hass) - with patch("apcaccess.status.parse", side_effect=OSError()), patch( - "apcaccess.status.get" + with ( + patch("apcaccess.status.parse", side_effect=OSError()), + patch("apcaccess.status.get"), ): await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_ERROR + assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_unload_remove(hass: HomeAssistant) -> None: - """Test successful unload of entry.""" +async def test_unload_remove_entry(hass: HomeAssistant) -> None: + """Test successful unload and removal of an entry.""" # Load two integrations from two mock hosts. entries = ( await async_init_integration(hass, host="test1", status=MOCK_STATUS), @@ -142,3 +145,41 @@ async def test_unload_remove(hass: HomeAssistant) -> None: await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + + +async def test_availability(hass: HomeAssistant) -> None: + """Ensure that we mark the entity's availability properly when network is down / back up.""" + await async_init_integration(hass) + + state = hass.states.get("sensor.ups_load") + assert state + assert state.state != STATE_UNAVAILABLE + assert pytest.approx(float(state.state)) == 14.0 + + with ( + patch("apcaccess.status.parse") as mock_parse, + patch("apcaccess.status.get", return_value=b""), + ): + # Mock a network error and then trigger an auto-polling event. + mock_parse.side_effect = OSError() + future = utcnow() + UPDATE_INTERVAL + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + # Sensors should be marked as unavailable. + state = hass.states.get("sensor.ups_load") + assert state + assert state.state == STATE_UNAVAILABLE + + # Reset the API to return a new status and update. + mock_parse.side_effect = None + mock_parse.return_value = MOCK_STATUS | {"LOADPCT": "15.0 Percent"} + future = future + UPDATE_INTERVAL + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + # Sensors should be online now with the new value. + state = hass.states.get("sensor.ups_load") + assert state + assert state.state != STATE_UNAVAILABLE + assert pytest.approx(float(state.state)) == 15.0 diff --git a/tests/components/apcupsd/test_sensor.py b/tests/components/apcupsd/test_sensor.py index 1b09e107682..bff1b858216 100644 --- a/tests/components/apcupsd/test_sensor.py +++ b/tests/components/apcupsd/test_sensor.py @@ -1,5 +1,9 @@ """Test sensors of APCUPSd integration.""" +from datetime import timedelta +from unittest.mock import patch + +from homeassistant.components.apcupsd.coordinator import REQUEST_REFRESH_COOLDOWN from homeassistant.components.sensor import ( ATTR_STATE_CLASS, SensorDeviceClass, @@ -7,28 +11,33 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, + STATE_UNAVAILABLE, UnitOfElectricPotential, UnitOfPower, UnitOfTime, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow from . import MOCK_STATUS, async_init_integration +from tests.common import async_fire_time_changed -async def test_sensor(hass: HomeAssistant) -> None: + +async def test_sensor(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test states of sensor.""" await async_init_integration(hass, status=MOCK_STATUS) - registry = er.async_get(hass) # Test a representative string sensor. state = hass.states.get("sensor.ups_mode") assert state assert state.state == "Stand Alone" - entry = registry.async_get("sensor.ups_mode") + entry = entity_registry.async_get("sensor.ups_mode") assert entry assert entry.unique_id == "XXXXXXXXXXXX_upsmode" @@ -41,7 +50,7 @@ async def test_sensor(hass: HomeAssistant) -> None: ) assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLTAGE - entry = registry.async_get("sensor.ups_input_voltage") + entry = entity_registry.async_get("sensor.ups_input_voltage") assert entry assert entry.unique_id == "XXXXXXXXXXXX_linev" @@ -53,7 +62,7 @@ async def test_sensor(hass: HomeAssistant) -> None: ) assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLTAGE - entry = registry.async_get("sensor.ups_battery_voltage") + entry = entity_registry.async_get("sensor.ups_battery_voltage") assert entry assert entry.unique_id == "XXXXXXXXXXXX_battv" @@ -62,7 +71,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state assert state.state == "7" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.DAYS - entry = registry.async_get("sensor.ups_self_test_interval") + entry = entity_registry.async_get("sensor.ups_self_test_interval") assert entry assert entry.unique_id == "XXXXXXXXXXXX_stesti" @@ -72,7 +81,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.state == "14.0" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.ups_load") + entry = entity_registry.async_get("sensor.ups_load") assert entry assert entry.unique_id == "XXXXXXXXXXXX_loadpct" @@ -82,26 +91,121 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.state == "330" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER - entry = registry.async_get("sensor.ups_nominal_output_power") + entry = entity_registry.async_get("sensor.ups_nominal_output_power") assert entry assert entry.unique_id == "XXXXXXXXXXXX_nompower" -async def test_sensor_disabled(hass: HomeAssistant) -> None: +async def test_sensor_disabled( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test sensor disabled by default.""" await async_init_integration(hass) - registry = er.async_get(hass) # Test a representative integration-disabled sensor. - entry = registry.async_get("sensor.ups_model") + entry = entity_registry.async_get("sensor.ups_model") assert entry.disabled assert entry.unique_id == "XXXXXXXXXXXX_model" assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Test enabling entity. - updated_entry = registry.async_update_entity( + updated_entry = entity_registry.async_update_entity( entry.entity_id, **{"disabled_by": None} ) assert updated_entry != entry assert updated_entry.disabled is False + + +async def test_state_update(hass: HomeAssistant) -> None: + """Ensure the sensor state changes after updating the data.""" + await async_init_integration(hass) + + state = hass.states.get("sensor.ups_load") + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "14.0" + + new_status = MOCK_STATUS | {"LOADPCT": "15.0 Percent"} + with ( + patch("apcaccess.status.parse", return_value=new_status), + patch("apcaccess.status.get", return_value=b""), + ): + future = utcnow() + timedelta(minutes=2) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("sensor.ups_load") + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "15.0" + + +async def test_manual_update_entity(hass: HomeAssistant) -> None: + """Test manual update entity via service homeassistant/update_entity.""" + await async_init_integration(hass) + + # Assert the initial state of sensor.ups_load. + state = hass.states.get("sensor.ups_load") + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "14.0" + + # Setup HASS for calling the update_entity service. + await async_setup_component(hass, "homeassistant", {}) + + with ( + patch("apcaccess.status.parse") as mock_parse, + patch("apcaccess.status.get", return_value=b"") as mock_get, + ): + mock_parse.return_value = MOCK_STATUS | { + "LOADPCT": "15.0 Percent", + "BCHARGE": "99.0 Percent", + } + # Now, we fast-forward the time to pass the debouncer cooldown, but put it + # before the normal update interval to see if the manual update works. + future = utcnow() + timedelta(seconds=REQUEST_REFRESH_COOLDOWN) + async_fire_time_changed(hass, future) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.ups_load", "sensor.ups_battery"]}, + blocking=True, + ) + # Even if we requested updates for two entities, our integration should smartly + # group the API calls to just one. + assert mock_parse.call_count == 1 + assert mock_get.call_count == 1 + + # The new state should be effective. + state = hass.states.get("sensor.ups_load") + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "15.0" + + +async def test_multiple_manual_update_entity(hass: HomeAssistant) -> None: + """Test multiple simultaneous manual update entity via service homeassistant/update_entity. + + We should only do network call once for the multiple simultaneous update entity services. + """ + await async_init_integration(hass) + + # Setup HASS for calling the update_entity service. + await async_setup_component(hass, "homeassistant", {}) + + with ( + patch("apcaccess.status.parse", return_value=MOCK_STATUS) as mock_parse, + patch("apcaccess.status.get", return_value=b"") as mock_get, + ): + # Fast-forward time to just pass the initial debouncer cooldown. + future = utcnow() + timedelta(seconds=REQUEST_REFRESH_COOLDOWN) + async_fire_time_changed(hass, future) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.ups_load", "sensor.ups_input_voltage"]}, + blocking=True, + ) + assert mock_parse.call_count == 1 + assert mock_get.call_count == 1 diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 2d570540341..08cb77b4559 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -1,9 +1,10 @@ """The tests for the Home Assistant API component.""" +import asyncio from http import HTTPStatus import json from unittest.mock import patch -from aiohttp import web +from aiohttp import ServerDisconnectedError, web from aiohttp.test_utils import TestClient import pytest import voluptuous as vol @@ -352,6 +353,46 @@ async def test_api_call_service_with_data( assert state["attributes"] == {"data": 1} +async def test_api_call_service_client_closed( + hass: HomeAssistant, mock_api_client: TestClient +) -> None: + """Test that services keep running if client is closed.""" + test_value = [] + + fut = hass.loop.create_future() + service_call_started = asyncio.Event() + + async def listener(service_call): + """Wait and return after mock_api_client.post finishes.""" + service_call_started.set() + value = await fut + test_value.append(value) + + hass.services.async_register("test_domain", "test_service", listener) + + api_task = hass.async_create_task( + mock_api_client.post("/api/services/test_domain/test_service") + ) + + await service_call_started.wait() + + assert len(test_value) == 0 + + await mock_api_client.close() + + assert len(test_value) == 0 + assert api_task.done() + + with pytest.raises(ServerDisconnectedError): + await api_task + + fut.set_result(1) + await hass.async_block_till_done() + + assert len(test_value) == 1 + assert test_value[0] == 1 + + async def test_api_template(hass: HomeAssistant, mock_api_client: TestClient) -> None: """Test the template API.""" hass.states.async_set("sensor.temperature", 10) diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index cc56894cf0d..807eff4ef8d 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -479,7 +479,7 @@ async def test_config_flow( resp = await client.cmd("delete", {"application_credentials_id": ID}) assert not resp.get("success") assert "error" in resp - assert resp["error"].get("code") == "unknown_error" + assert resp["error"].get("code") == "home_assistant_error" assert ( resp["error"].get("message") == "Cannot delete credential in use by integration fake_integration" diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 9eb7e1e5a05..072b1ff730a 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -487,6 +487,119 @@ # name: test_audio_pipeline_with_wake_word_timeout.3 None # --- +# name: test_device_capture + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 300, + }), + }) +# --- +# name: test_device_capture.1 + dict({ + 'engine': 'test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'language': 'en-US', + 'sample_rate': 16000, + }), + }) +# --- +# name: test_device_capture.2 + None +# --- +# name: test_device_capture_override + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 300, + }), + }) +# --- +# name: test_device_capture_override.1 + dict({ + 'engine': 'test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'language': 'en-US', + 'sample_rate': 16000, + }), + }) +# --- +# name: test_device_capture_override.2 + dict({ + 'audio': 'Y2h1bmsx', + 'channels': 1, + 'rate': 16000, + 'type': 'audio', + 'width': 2, + }) +# --- +# name: test_device_capture_override.3 + dict({ + 'stt_output': dict({ + 'text': 'test transcript', + }), + }) +# --- +# name: test_device_capture_override.4 + None +# --- +# name: test_device_capture_override.5 + dict({ + 'overflow': False, + 'type': 'end', + }) +# --- +# name: test_device_capture_queue_full + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 300, + }), + }) +# --- +# name: test_device_capture_queue_full.1 + dict({ + 'engine': 'test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'language': 'en-US', + 'sample_rate': 16000, + }), + }) +# --- +# name: test_device_capture_queue_full.2 + dict({ + 'stt_output': dict({ + 'text': 'test transcript', + }), + }) +# --- +# name: test_device_capture_queue_full.3 + None +# --- +# name: test_device_capture_queue_full.4 + dict({ + 'overflow': True, + 'type': 'end', + }) +# --- # name: test_intent_failed dict({ 'language': 'en', @@ -537,6 +650,33 @@ 'message': 'Timeout running pipeline', }) # --- +# name: test_pipeline_empty_tts_output + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': None, + 'timeout': 300, + }), + }) +# --- +# name: test_pipeline_empty_tts_output.1 + dict({ + 'engine': 'test', + 'language': 'en-US', + 'tts_input': '', + 'voice': 'james_earl_jones', + }) +# --- +# name: test_pipeline_empty_tts_output.2 + dict({ + 'tts_output': dict({ + }), + }) +# --- +# name: test_pipeline_empty_tts_output.3 + None +# --- # name: test_stt_provider_missing dict({ 'language': 'en', diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index a98858a1bce..24a4a92536d 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -9,7 +9,7 @@ import wave import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import assist_pipeline, stt +from homeassistant.components import assist_pipeline, stt, tts from homeassistant.components.assist_pipeline.const import ( CONF_DEBUG_RECORDING_DIR, DOMAIN, @@ -660,3 +660,42 @@ def test_pipeline_run_equality(hass: HomeAssistant, init_components) -> None: assert run_1 == run_1 assert run_1 != run_2 assert run_1 != 1234 + + +async def test_tts_audio_output( + hass: HomeAssistant, + mock_stt_provider: MockSttProvider, + init_components, + pipeline_data: assist_pipeline.pipeline.PipelineData, + snapshot: SnapshotAssertion, +) -> None: + """Test using tts_audio_output with wav sets options correctly.""" + + def event_callback(event): + pass + + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + tts_input="This is a test.", + conversation_id=None, + device_id=None, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.TTS, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=event_callback, + tts_audio_output="wav", + ), + ) + await pipeline_input.validate() + + # Verify TTS audio settings + assert pipeline_input.run.tts_options is not None + assert pipeline_input.run.tts_options.get(tts.ATTR_PREFERRED_FORMAT) == "wav" + assert pipeline_input.run.tts_options.get(tts.ATTR_PREFERRED_SAMPLE_RATE) == 16000 + assert pipeline_input.run.tts_options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS) == 1 diff --git a/tests/components/assist_pipeline/test_logbook.py b/tests/components/assist_pipeline/test_logbook.py new file mode 100644 index 00000000000..c1e0633ed57 --- /dev/null +++ b/tests/components/assist_pipeline/test_logbook.py @@ -0,0 +1,42 @@ +"""The tests for assist_pipeline logbook.""" +from homeassistant.components import assist_pipeline, logbook +from homeassistant.const import ATTR_DEVICE_ID +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.components.logbook.common import MockRow, mock_humanify + + +async def test_recording_event( + hass: HomeAssistant, init_components, device_registry: dr.DeviceRegistry +) -> None: + """Test recording event.""" + hass.config.components.add("recorder") + assert await async_setup_component(hass, "logbook", {}) + + entry = MockConfigEntry() + entry.add_to_hass(hass) + satellite_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("demo", "satellite-1234")}, + ) + + device_registry.async_update_device(satellite_device.id, name="My Satellite") + event = mock_humanify( + hass, + [ + MockRow( + assist_pipeline.EVENT_RECORDING, + {ATTR_DEVICE_ID: satellite_device.id}, + ), + ], + )[0] + + assert event[logbook.LOGBOOK_ENTRY_NAME] == "My Satellite" + assert event[logbook.LOGBOOK_ENTRY_DOMAIN] == assist_pipeline.DOMAIN + assert ( + event[logbook.LOGBOOK_ENTRY_MESSAGE] == "My Satellite captured an audio sample" + ) diff --git a/tests/components/assist_pipeline/test_select.py b/tests/components/assist_pipeline/test_select.py index 090c1034e4e..c4e750e1019 100644 --- a/tests/components/assist_pipeline/test_select.py +++ b/tests/components/assist_pipeline/test_select.py @@ -20,7 +20,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from tests.common import MockConfigEntry, MockPlatform, mock_entity_platform +from tests.common import MockConfigEntry, MockPlatform, mock_platform class SelectPlatform(MockPlatform): @@ -47,7 +47,7 @@ class SelectPlatform(MockPlatform): @pytest.fixture async def init_select(hass: HomeAssistant, init_components) -> ConfigEntry: """Initialize select entity.""" - mock_entity_platform(hass, "select.assist_pipeline", SelectPlatform()) + mock_platform(hass, "assist_pipeline.select", SelectPlatform()) config_entry = MockConfigEntry(domain="assist_pipeline") config_entry.add_to_hass(hass) assert await hass.config_entries.async_forward_entry_setup(config_entry, "select") @@ -102,10 +102,10 @@ async def test_select_entity_registering_device( hass: HomeAssistant, init_select: ConfigEntry, pipeline_data: PipelineData, + device_registry: dr.DeviceRegistry, ) -> None: """Test entity registering as an assist device.""" - dev_reg = dr.async_get(hass) - device = dev_reg.async_get_device(identifiers={("test", "test")}) + device = device_registry.async_get_device(identifiers={("test", "test")}) assert device is not None # Test device is registered diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index 9a4e78a29af..0e2a3ad538c 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -1,16 +1,23 @@ """Websocket tests for Voice Assistant integration.""" import asyncio +import base64 from unittest.mock import ANY, patch from syrupy.assertion import SnapshotAssertion from homeassistant.components.assist_pipeline.const import DOMAIN -from homeassistant.components.assist_pipeline.pipeline import Pipeline, PipelineData +from homeassistant.components.assist_pipeline.pipeline import ( + DeviceAudioQueue, + Pipeline, + PipelineData, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from .conftest import MockWakeWordEntity, MockWakeWordEntity2 +from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -2104,3 +2111,395 @@ async def test_wake_word_cooldown_different_entities( # Wake words should be the same assert ww_id_1 == ww_id_2 + + +async def test_device_capture( + hass: HomeAssistant, + init_components, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test audio capture from a satellite device.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + satellite_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("demo", "satellite-1234")}, + ) + + audio_chunks = [b"chunk1", b"chunk2", b"chunk3"] + + # Start capture + client_capture = await hass_ws_client(hass) + await client_capture.send_json_auto_id( + { + "type": "assist_pipeline/device/capture", + "timeout": 30, + "device_id": satellite_device.id, + } + ) + + # result + msg = await client_capture.receive_json() + assert msg["success"] + + # Run pipeline + client_pipeline = await hass_ws_client(hass) + await client_pipeline.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "stt", + "end_stage": "stt", + "input": { + "sample_rate": 16000, + "no_vad": True, + "no_chunking": True, + }, + "device_id": satellite_device.id, + } + ) + + # result + msg = await client_pipeline.receive_json() + assert msg["success"] + + # run start + msg = await client_pipeline.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] + + # stt + msg = await client_pipeline.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == snapshot + + for audio_chunk in audio_chunks: + await client_pipeline.send_bytes(bytes([handler_id]) + audio_chunk) + + # End of audio stream + await client_pipeline.send_bytes(bytes([handler_id])) + + msg = await client_pipeline.receive_json() + assert msg["event"]["type"] == "stt-end" + + # run end + msg = await client_pipeline.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + + # Verify capture + events = [] + async with asyncio.timeout(1): + while True: + msg = await client_capture.receive_json() + assert msg["type"] == "event" + event_data = msg["event"] + events.append(event_data) + if event_data["type"] == "end": + break + + assert len(events) == len(audio_chunks) + 1 + + # Verify audio chunks + for i, audio_chunk in enumerate(audio_chunks): + assert events[i]["type"] == "audio" + assert events[i]["rate"] == 16000 + assert events[i]["width"] == 2 + assert events[i]["channels"] == 1 + + # Audio is base64 encoded + assert events[i]["audio"] == base64.b64encode(audio_chunk).decode("ascii") + + # Last event is the end + assert events[-1]["type"] == "end" + + +async def test_device_capture_override( + hass: HomeAssistant, + init_components, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test overriding an existing audio capture from a satellite device.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + satellite_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("demo", "satellite-1234")}, + ) + + audio_chunks = [b"chunk1", b"chunk2", b"chunk3"] + + # Start first capture + client_capture_1 = await hass_ws_client(hass) + await client_capture_1.send_json_auto_id( + { + "type": "assist_pipeline/device/capture", + "timeout": 30, + "device_id": satellite_device.id, + } + ) + + # result + msg = await client_capture_1.receive_json() + assert msg["success"] + + # Run pipeline + client_pipeline = await hass_ws_client(hass) + await client_pipeline.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "stt", + "end_stage": "stt", + "input": { + "sample_rate": 16000, + "no_vad": True, + "no_chunking": True, + }, + "device_id": satellite_device.id, + } + ) + + # result + msg = await client_pipeline.receive_json() + assert msg["success"] + + # run start + msg = await client_pipeline.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] + + # stt + msg = await client_pipeline.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == snapshot + + # Send first audio chunk + await client_pipeline.send_bytes(bytes([handler_id]) + audio_chunks[0]) + + # Verify first capture + msg = await client_capture_1.receive_json() + assert msg["type"] == "event" + assert msg["event"] == snapshot + assert msg["event"]["audio"] == base64.b64encode(audio_chunks[0]).decode("ascii") + + # Start a new capture + client_capture_2 = await hass_ws_client(hass) + await client_capture_2.send_json_auto_id( + { + "type": "assist_pipeline/device/capture", + "timeout": 30, + "device_id": satellite_device.id, + } + ) + + # result (capture 2) + msg = await client_capture_2.receive_json() + assert msg["success"] + + # Send remaining audio chunks + for audio_chunk in audio_chunks[1:]: + await client_pipeline.send_bytes(bytes([handler_id]) + audio_chunk) + + # End of audio stream + await client_pipeline.send_bytes(bytes([handler_id])) + + msg = await client_pipeline.receive_json() + assert msg["event"]["type"] == "stt-end" + assert msg["event"]["data"] == snapshot + + # run end + msg = await client_pipeline.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + + # Verify that first capture ended with no more audio + msg = await client_capture_1.receive_json() + assert msg["type"] == "event" + assert msg["event"] == snapshot + assert msg["event"]["type"] == "end" + + # Verify that the second capture got the remaining audio + events = [] + async with asyncio.timeout(1): + while True: + msg = await client_capture_2.receive_json() + assert msg["type"] == "event" + event_data = msg["event"] + events.append(event_data) + if event_data["type"] == "end": + break + + # -1 since first audio chunk went to the first capture + assert len(events) == len(audio_chunks) + + # Verify all but first audio chunk + for i, audio_chunk in enumerate(audio_chunks[1:]): + assert events[i]["type"] == "audio" + assert events[i]["rate"] == 16000 + assert events[i]["width"] == 2 + assert events[i]["channels"] == 1 + + # Audio is base64 encoded + assert events[i]["audio"] == base64.b64encode(audio_chunk).decode("ascii") + + # Last event is the end + assert events[-1]["type"] == "end" + + +async def test_device_capture_queue_full( + hass: HomeAssistant, + init_components, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test audio capture from a satellite device when the recording queue fills up.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + satellite_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("demo", "satellite-1234")}, + ) + + class FakeQueue(asyncio.Queue): + """Queue that reports full for anything but None.""" + + def put_nowait(self, item): + if item is not None: + raise asyncio.QueueFull() + + super().put_nowait(item) + + with patch( + "homeassistant.components.assist_pipeline.websocket_api.DeviceAudioQueue" + ) as mock: + mock.return_value = DeviceAudioQueue(queue=FakeQueue()) + + # Start capture + client_capture = await hass_ws_client(hass) + await client_capture.send_json_auto_id( + { + "type": "assist_pipeline/device/capture", + "timeout": 30, + "device_id": satellite_device.id, + } + ) + + # result + msg = await client_capture.receive_json() + assert msg["success"] + + # Run pipeline + client_pipeline = await hass_ws_client(hass) + await client_pipeline.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "stt", + "end_stage": "stt", + "input": { + "sample_rate": 16000, + "no_vad": True, + "no_chunking": True, + }, + "device_id": satellite_device.id, + } + ) + + # result + msg = await client_pipeline.receive_json() + assert msg["success"] + + # run start + msg = await client_pipeline.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] + + # stt + msg = await client_pipeline.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == snapshot + + # Single sample will "overflow" the queue + await client_pipeline.send_bytes(bytes([handler_id, 0, 0])) + + # End of audio stream + await client_pipeline.send_bytes(bytes([handler_id])) + + msg = await client_pipeline.receive_json() + assert msg["event"]["type"] == "stt-end" + assert msg["event"]["data"] == snapshot + + msg = await client_pipeline.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + + # Queue should have been overflowed + async with asyncio.timeout(1): + msg = await client_capture.receive_json() + assert msg["type"] == "event" + assert msg["event"] == snapshot + assert msg["event"]["type"] == "end" + assert msg["event"]["overflow"] + + +async def test_pipeline_empty_tts_output( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test events from a pipeline run with a empty text-to-speech text.""" + events = [] + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "tts", + "end_stage": "tts", + "input": { + "text": "", + }, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # text-to-speech + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-end" + assert msg["event"]["data"] == snapshot + assert not msg["event"]["data"]["tts_output"] + events.append(msg["event"]) + + # run end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) diff --git a/tests/components/asuswrt/common.py b/tests/components/asuswrt/common.py new file mode 100644 index 00000000000..d3953416281 --- /dev/null +++ b/tests/components/asuswrt/common.py @@ -0,0 +1,66 @@ +"""Test code shared between test files.""" + +from aioasuswrt.asuswrt import Device as LegacyDevice +from pyasuswrt.asuswrt import Device as HttpDevice + +from homeassistant.components.asuswrt.const import ( + CONF_SSH_KEY, + MODE_ROUTER, + PROTOCOL_HTTP, + PROTOCOL_HTTPS, + PROTOCOL_SSH, + PROTOCOL_TELNET, +) +from homeassistant.const import ( + CONF_HOST, + CONF_MODE, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) + +ASUSWRT_BASE = "homeassistant.components.asuswrt" + +HOST = "myrouter.asuswrt.com" +ROUTER_MAC_ADDR = "a1:b2:c3:d4:e5:f6" + +CONFIG_DATA_TELNET = { + CONF_HOST: HOST, + CONF_PORT: 23, + CONF_PROTOCOL: PROTOCOL_TELNET, + CONF_USERNAME: "user", + CONF_PASSWORD: "pwd", + CONF_MODE: MODE_ROUTER, +} + +CONFIG_DATA_SSH = { + CONF_HOST: HOST, + CONF_PORT: 22, + CONF_PROTOCOL: PROTOCOL_SSH, + CONF_USERNAME: "user", + CONF_SSH_KEY: "aaaaa", + CONF_MODE: MODE_ROUTER, +} + +CONFIG_DATA_HTTP = { + CONF_HOST: HOST, + CONF_PORT: 80, + CONF_PROTOCOL: PROTOCOL_HTTPS, + CONF_USERNAME: "user", + CONF_PASSWORD: "pwd", +} + +MOCK_MACS = [ + "A1:B1:C1:D1:E1:F1", + "A2:B2:C2:D2:E2:F2", + "A3:B3:C3:D3:E3:F3", + "A4:B4:C4:D4:E4:F4", +] + + +def new_device(protocol, mac, ip, name): + """Return a new device for specific protocol.""" + if protocol in [PROTOCOL_HTTP, PROTOCOL_HTTPS]: + return HttpDevice(mac, ip, name, ROUTER_MAC_ADDR, None) + return LegacyDevice(mac, ip, name) diff --git a/tests/components/asuswrt/conftest.py b/tests/components/asuswrt/conftest.py new file mode 100644 index 00000000000..0f29c84c820 --- /dev/null +++ b/tests/components/asuswrt/conftest.py @@ -0,0 +1,145 @@ +"""Fixtures for Asuswrt component.""" + +from unittest.mock import Mock, patch + +from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy +from aioasuswrt.connection import TelnetConnection +from pyasuswrt.asuswrt import AsusWrtError, AsusWrtHttp +import pytest + +from homeassistant.components.asuswrt.const import PROTOCOL_HTTP, PROTOCOL_SSH + +from .common import ASUSWRT_BASE, MOCK_MACS, ROUTER_MAC_ADDR, new_device + +ASUSWRT_HTTP_LIB = f"{ASUSWRT_BASE}.bridge.AsusWrtHttp" +ASUSWRT_LEGACY_LIB = f"{ASUSWRT_BASE}.bridge.AsusWrtLegacy" + +MOCK_BYTES_TOTAL = [60000000000, 50000000000] +MOCK_BYTES_TOTAL_HTTP = dict(enumerate(MOCK_BYTES_TOTAL)) +MOCK_CURRENT_TRANSFER_RATES = [20000000, 10000000] +MOCK_CURRENT_TRANSFER_RATES_HTTP = dict(enumerate(MOCK_CURRENT_TRANSFER_RATES)) +MOCK_LOAD_AVG_HTTP = {"load_avg_1": 1.1, "load_avg_5": 1.2, "load_avg_15": 1.3} +MOCK_LOAD_AVG = list(MOCK_LOAD_AVG_HTTP.values()) +MOCK_TEMPERATURES_HTTP = {"2.4GHz": 40.2, "CPU": 71.2} +MOCK_TEMPERATURES = {**MOCK_TEMPERATURES_HTTP, "5.0GHz": 0} + + +@pytest.fixture(name="patch_setup_entry") +def mock_controller_patch_setup_entry(): + """Mock setting up a config entry.""" + with patch( + f"{ASUSWRT_BASE}.async_setup_entry", return_value=True + ) as setup_entry_mock: + yield setup_entry_mock + + +@pytest.fixture(name="mock_devices_legacy") +def mock_devices_legacy_fixture(): + """Mock a list of devices.""" + return { + MOCK_MACS[0]: new_device(PROTOCOL_SSH, MOCK_MACS[0], "192.168.1.2", "Test"), + MOCK_MACS[1]: new_device(PROTOCOL_SSH, MOCK_MACS[1], "192.168.1.3", "TestTwo"), + } + + +@pytest.fixture(name="mock_devices_http") +def mock_devices_http_fixture(): + """Mock a list of devices.""" + return { + MOCK_MACS[0]: new_device(PROTOCOL_HTTP, MOCK_MACS[0], "192.168.1.2", "Test"), + MOCK_MACS[1]: new_device(PROTOCOL_HTTP, MOCK_MACS[1], "192.168.1.3", "TestTwo"), + } + + +@pytest.fixture(name="mock_available_temps") +def mock_available_temps_fixture(): + """Mock a list of available temperature sensors.""" + return [True, False, True] + + +@pytest.fixture(name="connect_legacy") +def mock_controller_connect_legacy(mock_devices_legacy, mock_available_temps): + """Mock a successful connection with legacy library.""" + with patch(ASUSWRT_LEGACY_LIB, spec=AsusWrtLegacy) as service_mock: + service_mock.return_value.connection = Mock(spec=TelnetConnection) + service_mock.return_value.is_connected = True + service_mock.return_value.async_get_nvram.return_value = { + "label_mac": ROUTER_MAC_ADDR, + "model": "abcd", + "firmver": "efg", + "buildno": "123", + } + service_mock.return_value.async_get_connected_devices.return_value = ( + mock_devices_legacy + ) + service_mock.return_value.async_get_bytes_total.return_value = MOCK_BYTES_TOTAL + service_mock.return_value.async_get_current_transfer_rates.return_value = ( + MOCK_CURRENT_TRANSFER_RATES + ) + service_mock.return_value.async_get_loadavg.return_value = MOCK_LOAD_AVG + service_mock.return_value.async_get_temperature.return_value = MOCK_TEMPERATURES + service_mock.return_value.async_find_temperature_commands.return_value = ( + mock_available_temps + ) + yield service_mock + + +@pytest.fixture(name="connect_legacy_sens_fail") +def mock_controller_connect_legacy_sens_fail(connect_legacy): + """Mock a successful connection using legacy library with sensors fail.""" + connect_legacy.return_value.async_get_nvram.side_effect = OSError + connect_legacy.return_value.async_get_connected_devices.side_effect = OSError + connect_legacy.return_value.async_get_bytes_total.side_effect = OSError + connect_legacy.return_value.async_get_current_transfer_rates.side_effect = OSError + connect_legacy.return_value.async_get_loadavg.side_effect = OSError + connect_legacy.return_value.async_get_temperature.side_effect = OSError + connect_legacy.return_value.async_find_temperature_commands.return_value = [ + True, + True, + True, + ] + + +@pytest.fixture(name="connect_http") +def mock_controller_connect_http(mock_devices_http): + """Mock a successful connection with http library.""" + with patch(ASUSWRT_HTTP_LIB, spec_set=AsusWrtHttp) as service_mock: + service_mock.return_value.is_connected = True + service_mock.return_value.mac = ROUTER_MAC_ADDR + service_mock.return_value.model = "FAKE_MODEL" + service_mock.return_value.firmware = "FAKE_FIRMWARE" + service_mock.return_value.async_get_connected_devices.return_value = ( + mock_devices_http + ) + service_mock.return_value.async_get_traffic_bytes.return_value = ( + MOCK_BYTES_TOTAL_HTTP + ) + service_mock.return_value.async_get_traffic_rates.return_value = ( + MOCK_CURRENT_TRANSFER_RATES_HTTP + ) + service_mock.return_value.async_get_loadavg.return_value = MOCK_LOAD_AVG_HTTP + service_mock.return_value.async_get_temperatures.return_value = ( + MOCK_TEMPERATURES_HTTP + ) + yield service_mock + + +@pytest.fixture(name="connect_http_sens_fail") +def mock_controller_connect_http_sens_fail(connect_http): + """Mock a successful connection using http library with sensors fail.""" + connect_http.return_value.mac = None + connect_http.return_value.async_get_connected_devices.side_effect = AsusWrtError + connect_http.return_value.async_get_traffic_bytes.side_effect = AsusWrtError + connect_http.return_value.async_get_traffic_rates.side_effect = AsusWrtError + connect_http.return_value.async_get_loadavg.side_effect = AsusWrtError + connect_http.return_value.async_get_temperatures.side_effect = AsusWrtError + + +@pytest.fixture(name="connect_http_sens_detect") +def mock_controller_connect_http_sens_detect(): + """Mock a successful sensor detection using http library.""" + with patch( + f"{ASUSWRT_BASE}.bridge.AsusWrtHttpBridge._get_available_temperature_sensors", + return_value=[*MOCK_TEMPERATURES], + ) as mock_sens_detect: + yield mock_sens_detect diff --git a/tests/components/asuswrt/test_config_flow.py b/tests/components/asuswrt/test_config_flow.py index bdee4f82f90..0b5b0ace720 100644 --- a/tests/components/asuswrt/test_config_flow.py +++ b/tests/components/asuswrt/test_config_flow.py @@ -1,7 +1,8 @@ """Tests for the AsusWrt config flow.""" from socket import gaierror -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import patch +from pyasuswrt import AsusWrtError import pytest from homeassistant import data_entry_flow @@ -12,11 +13,16 @@ from homeassistant.components.asuswrt.const import ( CONF_SSH_KEY, CONF_TRACK_UNKNOWN, DOMAIN, + MODE_AP, + MODE_ROUTER, + PROTOCOL_HTTPS, + PROTOCOL_SSH, PROTOCOL_TELNET, ) from homeassistant.components.device_tracker import CONF_CONSIDER_HOME from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( + CONF_BASE, CONF_HOST, CONF_MODE, CONF_PASSWORD, @@ -26,92 +32,120 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant +from .common import ASUSWRT_BASE, HOST, ROUTER_MAC_ADDR + from tests.common import MockConfigEntry -HOST = "myrouter.asuswrt.com" -IP_ADDRESS = "192.168.1.1" -MAC_ADDR = "a1:b1:c1:d1:e1:f1" SSH_KEY = "1234" CONFIG_DATA = { CONF_HOST: HOST, - CONF_PORT: 23, - CONF_PROTOCOL: PROTOCOL_TELNET, CONF_USERNAME: "user", CONF_PASSWORD: "pwd", - CONF_MODE: "ap", } -PATCH_GET_HOST = patch( - "homeassistant.components.asuswrt.config_flow.socket.gethostbyname", - return_value=IP_ADDRESS, -) +CONFIG_DATA_HTTP = { + **CONFIG_DATA, + CONF_PROTOCOL: PROTOCOL_HTTPS, + CONF_PORT: 8443, +} -PATCH_SETUP_ENTRY = patch( - "homeassistant.components.asuswrt.async_setup_entry", - return_value=True, -) +CONFIG_DATA_SSH = { + **CONFIG_DATA, + CONF_PROTOCOL: PROTOCOL_SSH, + CONF_PORT: 22, +} + +CONFIG_DATA_TELNET = { + **CONFIG_DATA, + CONF_PROTOCOL: PROTOCOL_TELNET, + CONF_PORT: 23, +} -@pytest.fixture(name="mock_unique_id") -def mock_unique_id_fixture(): - """Mock returned unique id.""" - return {} +@pytest.fixture(name="patch_get_host", autouse=True) +def mock_controller_patch_get_host(): + """Mock call to socket gethostbyname function.""" + with patch( + f"{ASUSWRT_BASE}.config_flow.socket.gethostbyname", return_value="192.168.1.1" + ) as get_host_mock: + yield get_host_mock -@pytest.fixture(name="connect") -def mock_controller_connect(mock_unique_id): - """Mock a successful connection.""" - with patch("homeassistant.components.asuswrt.bridge.AsusWrtLegacy") as service_mock: - service_mock.return_value.connection.async_connect = AsyncMock() - service_mock.return_value.is_connected = True - service_mock.return_value.connection.disconnect = Mock() - service_mock.return_value.async_get_nvram = AsyncMock( - return_value=mock_unique_id - ) - yield service_mock +@pytest.fixture(name="patch_is_file", autouse=True) +def mock_controller_patch_is_file(): + """Mock call to os path.isfile function.""" + with patch( + f"{ASUSWRT_BASE}.config_flow.os.path.isfile", return_value=True + ) as is_file_mock: + yield is_file_mock -@pytest.mark.usefixtures("connect") -@pytest.mark.parametrize( - "unique_id", - [{}, {"label_mac": MAC_ADDR}], -) -async def test_user(hass: HomeAssistant, mock_unique_id, unique_id) -> None: +@pytest.mark.parametrize("unique_id", [{}, {"label_mac": ROUTER_MAC_ADDR}]) +async def test_user_legacy( + hass: HomeAssistant, connect_legacy, patch_setup_entry, unique_id +) -> None: """Test user config.""" - mock_unique_id.update(unique_id) flow_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER, "show_advanced_options": True} ) assert flow_result["type"] == data_entry_flow.FlowResultType.FORM assert flow_result["step_id"] == "user" + connect_legacy.return_value.async_get_nvram.return_value = unique_id + # test with all provided - with PATCH_GET_HOST, PATCH_SETUP_ENTRY as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - flow_result["flow_id"], - user_input=CONFIG_DATA, - ) - await hass.async_block_till_done() + legacy_result = await hass.config_entries.flow.async_configure( + flow_result["flow_id"], user_input=CONFIG_DATA_TELNET + ) + await hass.async_block_till_done() - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == HOST - assert result["data"] == CONFIG_DATA + assert legacy_result["type"] == data_entry_flow.FlowResultType.FORM + assert legacy_result["step_id"] == "legacy" - assert len(mock_setup_entry.mock_calls) == 1 + # complete configuration + result = await hass.config_entries.flow.async_configure( + legacy_result["flow_id"], user_input={CONF_MODE: MODE_AP} + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == {**CONFIG_DATA_TELNET, CONF_MODE: MODE_AP} + + assert len(patch_setup_entry.mock_calls) == 1 -@pytest.mark.parametrize( - ("config", "error"), - [ - ({CONF_PASSWORD: None}, "pwd_or_ssh"), - ({CONF_SSH_KEY: SSH_KEY}, "pwd_and_ssh"), - ], -) -async def test_error_wrong_password_ssh(hass: HomeAssistant, config, error) -> None: - """Test we abort for wrong password and ssh file combination.""" - config_data = CONFIG_DATA.copy() - config_data.update(config) +@pytest.mark.parametrize("unique_id", [None, ROUTER_MAC_ADDR]) +async def test_user_http( + hass: HomeAssistant, connect_http, patch_setup_entry, unique_id +) -> None: + """Test user config http.""" + flow_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER, "show_advanced_options": True} + ) + assert flow_result["type"] == data_entry_flow.FlowResultType.FORM + assert flow_result["step_id"] == "user" + + connect_http.return_value.mac = unique_id + + # test with all provided + result = await hass.config_entries.flow.async_configure( + flow_result["flow_id"], user_input=CONFIG_DATA_HTTP + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == CONFIG_DATA_HTTP + + assert len(patch_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize("config", [CONFIG_DATA_TELNET, CONFIG_DATA_HTTP]) +async def test_error_pwd_required(hass: HomeAssistant, config) -> None: + """Test we abort for missing password.""" + config_data = {k: v for k, v in config.items() if k != CONF_PASSWORD} result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER, "show_advanced_options": True}, @@ -119,105 +153,109 @@ async def test_error_wrong_password_ssh(hass: HomeAssistant, config, error) -> N ) assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"] == {"base": error} + assert result["errors"] == {CONF_BASE: "pwd_required"} -async def test_error_invalid_ssh(hass: HomeAssistant) -> None: +async def test_error_no_password_ssh(hass: HomeAssistant) -> None: + """Test we abort for wrong password and ssh file combination.""" + config_data = {k: v for k, v in CONFIG_DATA_SSH.items() if k != CONF_PASSWORD} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER, "show_advanced_options": True}, + data=config_data, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {CONF_BASE: "pwd_or_ssh"} + + +async def test_error_invalid_ssh(hass: HomeAssistant, patch_is_file) -> None: """Test we abort if invalid ssh file is provided.""" - config_data = CONFIG_DATA.copy() - config_data.pop(CONF_PASSWORD) + config_data = {k: v for k, v in CONFIG_DATA_SSH.items() if k != CONF_PASSWORD} config_data[CONF_SSH_KEY] = SSH_KEY - with patch( - "homeassistant.components.asuswrt.config_flow.os.path.isfile", - return_value=False, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER, "show_advanced_options": True}, - data=config_data, - ) + patch_is_file.return_value = False + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER, "show_advanced_options": True}, + data=config_data, + ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"] == {"base": "ssh_not_file"} + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {CONF_BASE: "ssh_not_file"} -async def test_error_invalid_host(hass: HomeAssistant) -> None: +async def test_error_invalid_host(hass: HomeAssistant, patch_get_host) -> None: """Test we abort if host name is invalid.""" - with patch( - "homeassistant.components.asuswrt.config_flow.socket.gethostbyname", - side_effect=gaierror, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=CONFIG_DATA, - ) + patch_get_host.side_effect = gaierror + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=CONFIG_DATA_TELNET, + ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"] == {"base": "invalid_host"} + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {CONF_BASE: "invalid_host"} async def test_abort_if_not_unique_id_setup(hass: HomeAssistant) -> None: """Test we abort if component without uniqueid is already setup.""" MockConfigEntry( domain=DOMAIN, - data=CONFIG_DATA, + data=CONFIG_DATA_TELNET, ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data=CONFIG_DATA, + data=CONFIG_DATA_TELNET, ) assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "no_unique_id" -@pytest.mark.usefixtures("connect") -async def test_update_uniqueid_exist(hass: HomeAssistant, mock_unique_id) -> None: +async def test_update_uniqueid_exist( + hass: HomeAssistant, connect_http, patch_setup_entry +) -> None: """Test we update entry if uniqueid is already configured.""" - mock_unique_id.update({"label_mac": MAC_ADDR}) existing_entry = MockConfigEntry( domain=DOMAIN, - data={**CONFIG_DATA, CONF_HOST: "10.10.10.10"}, - unique_id=MAC_ADDR, + data={**CONFIG_DATA_HTTP, CONF_HOST: "10.10.10.10"}, + unique_id=ROUTER_MAC_ADDR, ) existing_entry.add_to_hass(hass) - # test with all provided - with PATCH_GET_HOST, PATCH_SETUP_ENTRY: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER, "show_advanced_options": True}, - data=CONFIG_DATA, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER, "show_advanced_options": True}, + data=CONFIG_DATA_HTTP, + ) + await hass.async_block_till_done() - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == HOST - assert result["data"] == CONFIG_DATA - prev_entry = hass.config_entries.async_get_entry(existing_entry.entry_id) - assert not prev_entry + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == CONFIG_DATA_HTTP + prev_entry = hass.config_entries.async_get_entry(existing_entry.entry_id) + assert not prev_entry -@pytest.mark.usefixtures("connect") -async def test_abort_invalid_unique_id(hass: HomeAssistant) -> None: +async def test_abort_invalid_unique_id(hass: HomeAssistant, connect_legacy) -> None: """Test we abort if uniqueid not available.""" MockConfigEntry( domain=DOMAIN, - data=CONFIG_DATA, - unique_id=MAC_ADDR, + data=CONFIG_DATA_TELNET, + unique_id=ROUTER_MAC_ADDR, ).add_to_hass(hass) - with PATCH_GET_HOST: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=CONFIG_DATA, - ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "invalid_unique_id" + connect_legacy.return_value.async_get_nvram.return_value = {} + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=CONFIG_DATA_TELNET, + ) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "invalid_unique_id" @pytest.mark.parametrize( @@ -228,95 +266,158 @@ async def test_abort_invalid_unique_id(hass: HomeAssistant) -> None: (None, "cannot_connect"), ], ) -async def test_on_connect_failed(hass: HomeAssistant, side_effect, error) -> None: - """Test when we have errors connecting the router.""" +async def test_on_connect_legacy_failed( + hass: HomeAssistant, connect_legacy, side_effect, error +) -> None: + """Test when we have errors connecting the router with legacy library.""" flow_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER, "show_advanced_options": True}, ) - with PATCH_GET_HOST, patch( - "homeassistant.components.asuswrt.bridge.AsusWrtLegacy" - ) as asus_wrt: - asus_wrt.return_value.connection.async_connect = AsyncMock( - side_effect=side_effect - ) - asus_wrt.return_value.async_get_nvram = AsyncMock(return_value={}) - asus_wrt.return_value.is_connected = False + connect_legacy.return_value.is_connected = False + connect_legacy.return_value.connection.async_connect.side_effect = side_effect - result = await hass.config_entries.flow.async_configure( - flow_result["flow_id"], user_input=CONFIG_DATA - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"] == {"base": error} + # go to legacy form + result = await hass.config_entries.flow.async_configure( + flow_result["flow_id"], user_input=CONFIG_DATA_TELNET + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {CONF_BASE: error} -async def test_options_flow_ap(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (AsusWrtError, "cannot_connect"), + (TypeError, "unknown"), + (None, "cannot_connect"), + ], +) +async def test_on_connect_http_failed( + hass: HomeAssistant, connect_http, side_effect, error +) -> None: + """Test when we have errors connecting the router with http library.""" + flow_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER, "show_advanced_options": True}, + ) + + connect_http.return_value.is_connected = False + connect_http.return_value.async_connect.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + flow_result["flow_id"], user_input=CONFIG_DATA_HTTP + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {CONF_BASE: error} + + +async def test_options_flow_ap(hass: HomeAssistant, patch_setup_entry) -> None: """Test config flow options for ap mode.""" config_entry = MockConfigEntry( domain=DOMAIN, - data=CONFIG_DATA, + data={**CONFIG_DATA_TELNET, CONF_MODE: MODE_AP}, options={CONF_REQUIRE_IP: True}, ) config_entry.add_to_hass(hass) - with PATCH_SETUP_ENTRY: - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - assert CONF_REQUIRE_IP in result["data_schema"].schema + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + assert CONF_REQUIRE_IP in result["data_schema"].schema - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_CONSIDER_HOME: 20, - CONF_TRACK_UNKNOWN: True, - CONF_INTERFACE: "aaa", - CONF_DNSMASQ: "bbb", - CONF_REQUIRE_IP: False, - }, - ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CONSIDER_HOME: 20, + CONF_TRACK_UNKNOWN: True, + CONF_INTERFACE: "aaa", + CONF_DNSMASQ: "bbb", + CONF_REQUIRE_IP: False, + }, + ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert config_entry.options[CONF_CONSIDER_HOME] == 20 - assert config_entry.options[CONF_TRACK_UNKNOWN] is True - assert config_entry.options[CONF_INTERFACE] == "aaa" - assert config_entry.options[CONF_DNSMASQ] == "bbb" - assert config_entry.options[CONF_REQUIRE_IP] is False + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert config_entry.options == { + CONF_CONSIDER_HOME: 20, + CONF_TRACK_UNKNOWN: True, + CONF_INTERFACE: "aaa", + CONF_DNSMASQ: "bbb", + CONF_REQUIRE_IP: False, + } -async def test_options_flow_router(hass: HomeAssistant) -> None: +async def test_options_flow_router(hass: HomeAssistant, patch_setup_entry) -> None: """Test config flow options for router mode.""" config_entry = MockConfigEntry( domain=DOMAIN, - data={**CONFIG_DATA, CONF_MODE: "router"}, + data={**CONFIG_DATA_TELNET, CONF_MODE: MODE_ROUTER}, ) config_entry.add_to_hass(hass) - with PATCH_SETUP_ENTRY: - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - assert CONF_REQUIRE_IP not in result["data_schema"].schema + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + assert CONF_REQUIRE_IP not in result["data_schema"].schema - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_CONSIDER_HOME: 20, - CONF_TRACK_UNKNOWN: True, - CONF_INTERFACE: "aaa", - CONF_DNSMASQ: "bbb", - }, - ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CONSIDER_HOME: 20, + CONF_TRACK_UNKNOWN: True, + CONF_INTERFACE: "aaa", + CONF_DNSMASQ: "bbb", + }, + ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert config_entry.options[CONF_CONSIDER_HOME] == 20 - assert config_entry.options[CONF_TRACK_UNKNOWN] is True - assert config_entry.options[CONF_INTERFACE] == "aaa" - assert config_entry.options[CONF_DNSMASQ] == "bbb" + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert config_entry.options == { + CONF_CONSIDER_HOME: 20, + CONF_TRACK_UNKNOWN: True, + CONF_INTERFACE: "aaa", + CONF_DNSMASQ: "bbb", + } + + +async def test_options_flow_http(hass: HomeAssistant, patch_setup_entry) -> None: + """Test config flow options for http mode.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={**CONFIG_DATA_HTTP, CONF_MODE: MODE_ROUTER}, + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + assert CONF_INTERFACE not in result["data_schema"].schema + assert CONF_DNSMASQ not in result["data_schema"].schema + assert CONF_REQUIRE_IP not in result["data_schema"].schema + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CONSIDER_HOME: 20, + CONF_TRACK_UNKNOWN: True, + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert config_entry.options == { + CONF_CONSIDER_HOME: 20, + CONF_TRACK_UNKNOWN: True, + } diff --git a/tests/components/asuswrt/test_diagnostics.py b/tests/components/asuswrt/test_diagnostics.py new file mode 100644 index 00000000000..1c09dd29adc --- /dev/null +++ b/tests/components/asuswrt/test_diagnostics.py @@ -0,0 +1,41 @@ +"""Tests for the diagnostics data provided by the AsusWRT integration.""" + +from homeassistant.components.asuswrt.const import DOMAIN +from homeassistant.components.asuswrt.diagnostics import TO_REDACT +from homeassistant.components.device_tracker import CONF_CONSIDER_HOME +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .common import CONFIG_DATA_TELNET, ROUTER_MAC_ADDR + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + connect_legacy, +) -> None: + """Test diagnostics.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG_DATA_TELNET, + options={CONF_CONSIDER_HOME: 60}, + unique_id=ROUTER_MAC_ADDR, + ) + 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 == ConfigEntryState.LOADED + + entry_dict = async_redact_data(mock_config_entry.as_dict(), TO_REDACT) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result["entry"] == entry_dict diff --git a/tests/components/asuswrt/test_init.py b/tests/components/asuswrt/test_init.py new file mode 100644 index 00000000000..72897b737e5 --- /dev/null +++ b/tests/components/asuswrt/test_init.py @@ -0,0 +1,30 @@ +"""Tests for the AsusWrt integration.""" + +from homeassistant.components.asuswrt.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant + +from .common import CONFIG_DATA_TELNET, ROUTER_MAC_ADDR + +from tests.common import MockConfigEntry + + +async def test_disconnect_on_stop(hass: HomeAssistant, connect_legacy) -> None: + """Test we close the connection with the router when Home Assistants stops.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG_DATA_TELNET, + unique_id=ROUTER_MAC_ADDR, + ) + 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 + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + assert connect_legacy.return_value.connection.disconnect.call_count == 1 + assert config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index 52525390666..a7b19bb3785 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -1,16 +1,13 @@ """Tests for the AsusWrt sensor.""" from datetime import timedelta -from unittest.mock import AsyncMock, Mock, patch -from aioasuswrt.asuswrt import Device +from pyasuswrt.asuswrt import AsusWrtError import pytest from homeassistant.components import device_tracker, sensor from homeassistant.components.asuswrt.const import ( CONF_INTERFACE, DOMAIN, - MODE_ROUTER, - PROTOCOL_TELNET, SENSORS_BYTES, SENSORS_LOAD_AVG, SENSORS_RATES, @@ -19,12 +16,7 @@ from homeassistant.components.asuswrt.const import ( from homeassistant.components.device_tracker import CONF_CONSIDER_HOME from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( - CONF_HOST, - CONF_MODE, - CONF_PASSWORD, - CONF_PORT, CONF_PROTOCOL, - CONF_USERNAME, STATE_HOME, STATE_NOT_HOME, STATE_UNAVAILABLE, @@ -34,141 +26,39 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import slugify from homeassistant.util.dt import utcnow -from tests.common import MockConfigEntry, async_fire_time_changed - -ASUSWRT_LIB = "homeassistant.components.asuswrt.bridge.AsusWrtLegacy" - -HOST = "myrouter.asuswrt.com" -IP_ADDRESS = "192.168.1.1" - -CONFIG_DATA = { - CONF_HOST: HOST, - CONF_PORT: 22, - CONF_PROTOCOL: PROTOCOL_TELNET, - CONF_USERNAME: "user", - CONF_PASSWORD: "pwd", - CONF_MODE: MODE_ROUTER, -} - -MAC_ADDR = "a1:b2:c3:d4:e5:f6" - -MOCK_BYTES_TOTAL = [60000000000, 50000000000] -MOCK_CURRENT_TRANSFER_RATES = [20000000, 10000000] -MOCK_LOAD_AVG = [1.1, 1.2, 1.3] -MOCK_TEMPERATURES = {"2.4GHz": 40.0, "5.0GHz": 0.0, "CPU": 71.2} -MOCK_MAC_1 = "A1:B1:C1:D1:E1:F1" -MOCK_MAC_2 = "A2:B2:C2:D2:E2:F2" -MOCK_MAC_3 = "A3:B3:C3:D3:E3:F3" -MOCK_MAC_4 = "A4:B4:C4:D4:E4:F4" - -SENSORS_DEFAULT = [*SENSORS_BYTES, *SENSORS_RATES] -SENSORS_ALL = [*SENSORS_DEFAULT, *SENSORS_LOAD_AVG, *SENSORS_TEMPERATURES] - -PATCH_SETUP_ENTRY = patch( - "homeassistant.components.asuswrt.async_setup_entry", - return_value=True, +from .common import ( + CONFIG_DATA_HTTP, + CONFIG_DATA_TELNET, + HOST, + MOCK_MACS, + ROUTER_MAC_ADDR, + new_device, ) +from tests.common import MockConfigEntry, async_fire_time_changed -def new_device(mac, ip, name): - """Return a new device for specific protocol.""" - return Device(mac, ip, name) +SENSORS_DEFAULT = [*SENSORS_BYTES, *SENSORS_RATES] - -@pytest.fixture(name="mock_devices") -def mock_devices_fixture(): - """Mock a list of devices.""" - return { - MOCK_MAC_1: Device(MOCK_MAC_1, "192.168.1.2", "Test"), - MOCK_MAC_2: Device(MOCK_MAC_2, "192.168.1.3", "TestTwo"), - } - - -@pytest.fixture(name="mock_available_temps") -def mock_available_temps_fixture(): - """Mock a list of available temperature sensors.""" - return [True, False, True] +SENSORS_ALL_LEGACY = [*SENSORS_DEFAULT, *SENSORS_LOAD_AVG, *SENSORS_TEMPERATURES] +SENSORS_ALL_HTTP = [*SENSORS_DEFAULT, *SENSORS_LOAD_AVG, *SENSORS_TEMPERATURES] @pytest.fixture(name="create_device_registry_devices") -def create_device_registry_devices_fixture(hass: HomeAssistant): +def create_device_registry_devices_fixture( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +): """Create device registry devices so the device tracker entities are enabled when added.""" - dev_reg = dr.async_get(hass) config_entry = MockConfigEntry(domain="something_else") config_entry.add_to_hass(hass) - for idx, device in enumerate( - ( - MOCK_MAC_3, - MOCK_MAC_4, - ) - ): - dev_reg.async_get_or_create( + for idx, device in enumerate((MOCK_MACS[2], MOCK_MACS[3])): + device_registry.async_get_or_create( name=f"Device {idx}", config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(device))}, ) -@pytest.fixture(name="connect") -def mock_controller_connect(mock_devices, mock_available_temps): - """Mock a successful connection with AsusWrt library.""" - with patch(ASUSWRT_LIB) as service_mock: - service_mock.return_value.connection.async_connect = AsyncMock() - service_mock.return_value.is_connected = True - service_mock.return_value.connection.disconnect = Mock() - service_mock.return_value.async_get_nvram = AsyncMock( - return_value={ - "label_mac": MAC_ADDR, - "model": "abcd", - "firmver": "efg", - "buildno": "123", - } - ) - service_mock.return_value.async_get_connected_devices = AsyncMock( - return_value=mock_devices - ) - service_mock.return_value.async_get_bytes_total = AsyncMock( - return_value=MOCK_BYTES_TOTAL - ) - service_mock.return_value.async_get_current_transfer_rates = AsyncMock( - return_value=MOCK_CURRENT_TRANSFER_RATES - ) - service_mock.return_value.async_get_loadavg = AsyncMock( - return_value=MOCK_LOAD_AVG - ) - service_mock.return_value.async_get_temperature = AsyncMock( - return_value=MOCK_TEMPERATURES - ) - service_mock.return_value.async_find_temperature_commands = AsyncMock( - return_value=mock_available_temps - ) - yield service_mock - - -@pytest.fixture(name="connect_sens_fail") -def mock_controller_connect_sens_fail(): - """Mock a successful connection using AsusWrt library with sensors failing.""" - with patch(ASUSWRT_LIB) as service_mock: - service_mock.return_value.connection.async_connect = AsyncMock() - service_mock.return_value.is_connected = True - service_mock.return_value.connection.disconnect = Mock() - service_mock.return_value.async_get_nvram = AsyncMock(side_effect=OSError) - service_mock.return_value.async_get_connected_devices = AsyncMock( - side_effect=OSError - ) - service_mock.return_value.async_get_bytes_total = AsyncMock(side_effect=OSError) - service_mock.return_value.async_get_current_transfer_rates = AsyncMock( - side_effect=OSError - ) - service_mock.return_value.async_get_loadavg = AsyncMock(side_effect=OSError) - service_mock.return_value.async_get_temperature = AsyncMock(side_effect=OSError) - service_mock.return_value.async_find_temperature_commands = AsyncMock( - return_value=[True, True, True] - ) - yield service_mock - - def _setup_entry(hass: HomeAssistant, config, sensors, unique_id=None): """Create mock config entry with enabled sensors.""" entity_reg = er.async_get(hass) @@ -201,28 +91,23 @@ def _setup_entry(hass: HomeAssistant, config, sensors, unique_id=None): return config_entry, sensor_prefix -@pytest.mark.parametrize( - "entry_unique_id", - [None, MAC_ADDR], -) -async def test_sensors( +async def _test_sensors( hass: HomeAssistant, - connect, mock_devices, - create_device_registry_devices, + config, entry_unique_id, ) -> None: """Test creating AsusWRT default sensors and tracker.""" config_entry, sensor_prefix = _setup_entry( - hass, CONFIG_DATA, SENSORS_DEFAULT, entry_unique_id + hass, config, SENSORS_DEFAULT, entry_unique_id ) # Create the first device tracker to test mac conversion entity_reg = er.async_get(hass) for mac, name in { - MOCK_MAC_1: "test", - dr.format_mac(MOCK_MAC_2): "testtwo", - MOCK_MAC_2: "testremove", + MOCK_MACS[0]: "test", + dr.format_mac(MOCK_MACS[1]): "testtwo", + MOCK_MACS[1]: "testremove", }.items(): entity_reg.async_get_or_create( device_tracker.DOMAIN, @@ -250,7 +135,7 @@ async def test_sensors( assert hass.states.get(f"{sensor_prefix}_devices_connected").state == "2" # remove first tracked device - mock_devices.pop(MOCK_MAC_1) + mock_devices.pop(MOCK_MACS[0]) async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -261,8 +146,12 @@ async def test_sensors( assert hass.states.get(f"{sensor_prefix}_devices_connected").state == "1" # add 2 new devices, one unnamed that should be ignored but counted - mock_devices[MOCK_MAC_3] = new_device(MOCK_MAC_3, "192.168.1.4", "TestThree") - mock_devices[MOCK_MAC_4] = new_device(MOCK_MAC_4, "192.168.1.5", None) + mock_devices[MOCK_MACS[2]] = new_device( + config[CONF_PROTOCOL], MOCK_MACS[2], "192.168.1.4", "TestThree" + ) + mock_devices[MOCK_MACS[3]] = new_device( + config[CONF_PROTOCOL], MOCK_MACS[3], "192.168.1.5", None + ) # change consider home settings to have status not home of removed tracked device hass.config_entries.async_update_entry( @@ -279,12 +168,39 @@ async def test_sensors( assert hass.states.get(f"{sensor_prefix}_devices_connected").state == "3" -async def test_loadavg_sensors( +@pytest.mark.parametrize( + "entry_unique_id", + [None, ROUTER_MAC_ADDR], +) +async def test_sensors_legacy( hass: HomeAssistant, - connect, + connect_legacy, + mock_devices_legacy, + create_device_registry_devices, + entry_unique_id, ) -> None: + """Test creating AsusWRT default sensors and tracker with legacy protocol.""" + await _test_sensors(hass, mock_devices_legacy, CONFIG_DATA_TELNET, entry_unique_id) + + +@pytest.mark.parametrize( + "entry_unique_id", + [None, ROUTER_MAC_ADDR], +) +async def test_sensors_http( + hass: HomeAssistant, + connect_http, + mock_devices_http, + create_device_registry_devices, + entry_unique_id, +) -> None: + """Test creating AsusWRT default sensors and tracker with http protocol.""" + await _test_sensors(hass, mock_devices_http, CONFIG_DATA_HTTP, entry_unique_id) + + +async def _test_loadavg_sensors(hass: HomeAssistant, config) -> None: """Test creating an AsusWRT load average sensors.""" - config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA, SENSORS_LOAD_AVG) + config_entry, sensor_prefix = _setup_entry(hass, config, SENSORS_LOAD_AVG) config_entry.add_to_hass(hass) # initial devices setup @@ -299,12 +215,38 @@ async def test_loadavg_sensors( assert hass.states.get(f"{sensor_prefix}_sensor_load_avg15").state == "1.3" -async def test_temperature_sensors( - hass: HomeAssistant, - connect, +async def test_loadavg_sensors_legacy(hass: HomeAssistant, connect_legacy) -> None: + """Test creating an AsusWRT load average sensors.""" + await _test_loadavg_sensors(hass, CONFIG_DATA_TELNET) + + +async def test_loadavg_sensors_http(hass: HomeAssistant, connect_http) -> None: + """Test creating an AsusWRT load average sensors.""" + await _test_loadavg_sensors(hass, CONFIG_DATA_HTTP) + + +async def test_temperature_sensors_http_fail( + hass: HomeAssistant, connect_http_sens_fail ) -> None: + """Test fail creating AsusWRT temperature sensors.""" + config_entry, sensor_prefix = _setup_entry( + hass, CONFIG_DATA_HTTP, SENSORS_TEMPERATURES + ) + config_entry.add_to_hass(hass) + + # initial devices setup + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # assert temperature availability exception is handled correctly + assert not hass.states.get(f"{sensor_prefix}_2_4ghz") + assert not hass.states.get(f"{sensor_prefix}_5_0ghz") + assert not hass.states.get(f"{sensor_prefix}_cpu") + + +async def _test_temperature_sensors(hass: HomeAssistant, config) -> None: """Test creating a AsusWRT temperature sensors.""" - config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA, SENSORS_TEMPERATURES) + config_entry, sensor_prefix = _setup_entry(hass, config, SENSORS_TEMPERATURES) config_entry.add_to_hass(hass) # initial devices setup @@ -314,41 +256,74 @@ async def test_temperature_sensors( await hass.async_block_till_done() # assert temperature sensor available - assert hass.states.get(f"{sensor_prefix}_2_4ghz").state == "40.0" + assert hass.states.get(f"{sensor_prefix}_2_4ghz").state == "40.2" assert not hass.states.get(f"{sensor_prefix}_5_0ghz") assert hass.states.get(f"{sensor_prefix}_cpu").state == "71.2" +async def test_temperature_sensors_legacy(hass: HomeAssistant, connect_legacy) -> None: + """Test creating a AsusWRT temperature sensors.""" + await _test_temperature_sensors(hass, CONFIG_DATA_TELNET) + + +async def test_temperature_sensors_http(hass: HomeAssistant, connect_http) -> None: + """Test creating a AsusWRT temperature sensors.""" + await _test_temperature_sensors(hass, CONFIG_DATA_HTTP) + + @pytest.mark.parametrize( "side_effect", [OSError, None], ) -async def test_connect_fail(hass: HomeAssistant, side_effect) -> None: +async def test_connect_fail_legacy( + hass: HomeAssistant, connect_legacy, side_effect +) -> None: """Test AsusWRT connect fail.""" # init config entry config_entry = MockConfigEntry( domain=DOMAIN, - data=CONFIG_DATA, + data=CONFIG_DATA_TELNET, ) config_entry.add_to_hass(hass) - with patch(ASUSWRT_LIB) as asus_wrt: - asus_wrt.return_value.connection.async_connect = AsyncMock( - side_effect=side_effect - ) - asus_wrt.return_value.async_get_nvram = AsyncMock() - asus_wrt.return_value.is_connected = False + connect_legacy.return_value.connection.async_connect.side_effect = side_effect + connect_legacy.return_value.is_connected = False - # initial setup fail - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.SETUP_RETRY + # initial setup fail + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_sensors_polling_fails(hass: HomeAssistant, connect_sens_fail) -> None: +@pytest.mark.parametrize( + "side_effect", + [AsusWrtError, None], +) +async def test_connect_fail_http( + hass: HomeAssistant, connect_http, side_effect +) -> None: + """Test AsusWRT connect fail.""" + + # init config entry + config_entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG_DATA_HTTP, + ) + config_entry.add_to_hass(hass) + + connect_http.return_value.async_connect.side_effect = side_effect + connect_http.return_value.is_connected = False + + # initial setup fail + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def _test_sensors_polling_fails(hass: HomeAssistant, config, sensors) -> None: """Test AsusWRT sensors are unavailable when polling fails.""" - config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA, SENSORS_ALL) + config_entry, sensor_prefix = _setup_entry(hass, config, sensors) config_entry.add_to_hass(hass) # initial devices setup @@ -357,7 +332,7 @@ async def test_sensors_polling_fails(hass: HomeAssistant, connect_sens_fail) -> async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() - for sensor_name in SENSORS_ALL: + for sensor_name in sensors: assert ( hass.states.get(f"{sensor_prefix}_{slugify(sensor_name)}").state == STATE_UNAVAILABLE @@ -365,42 +340,65 @@ async def test_sensors_polling_fails(hass: HomeAssistant, connect_sens_fail) -> assert hass.states.get(f"{sensor_prefix}_devices_connected").state == "0" -async def test_options_reload(hass: HomeAssistant, connect) -> None: +async def test_sensors_polling_fails_legacy( + hass: HomeAssistant, + connect_legacy_sens_fail, +) -> None: + """Test AsusWRT sensors are unavailable when polling fails.""" + await _test_sensors_polling_fails(hass, CONFIG_DATA_TELNET, SENSORS_ALL_LEGACY) + + +async def test_sensors_polling_fails_http( + hass: HomeAssistant, + connect_http_sens_fail, + connect_http_sens_detect, +) -> None: + """Test AsusWRT sensors are unavailable when polling fails.""" + await _test_sensors_polling_fails(hass, CONFIG_DATA_HTTP, SENSORS_ALL_HTTP) + + +async def test_options_reload(hass: HomeAssistant, connect_legacy) -> None: """Test AsusWRT integration is reload changing an options that require this.""" - config_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_DATA, unique_id=MAC_ADDR) + config_entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG_DATA_TELNET, + unique_id=ROUTER_MAC_ADDR, + ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + assert connect_legacy.return_value.connection.async_connect.call_count == 1 + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() - with PATCH_SETUP_ENTRY as setup_entry_call: - # change an option that requires integration reload - hass.config_entries.async_update_entry( - config_entry, options={CONF_INTERFACE: "eth1"} - ) - await hass.async_block_till_done() + # change an option that requires integration reload + hass.config_entries.async_update_entry( + config_entry, options={CONF_INTERFACE: "eth1"} + ) + await hass.async_block_till_done() - assert setup_entry_call.called - assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED + assert connect_legacy.return_value.connection.async_connect.call_count == 2 -async def test_unique_id_migration(hass: HomeAssistant, connect) -> None: +async def test_unique_id_migration( + hass: HomeAssistant, entity_registry: er.EntityRegistry, connect_legacy +) -> None: """Test AsusWRT entities unique id format migration.""" config_entry = MockConfigEntry( domain=DOMAIN, - data=CONFIG_DATA, - unique_id=MAC_ADDR, + data=CONFIG_DATA_TELNET, + unique_id=ROUTER_MAC_ADDR, ) config_entry.add_to_hass(hass) - entity_reg = er.async_get(hass) obj_entity_id = slugify(f"{HOST} Upload") - entity_reg.async_get_or_create( + entity_registry.async_get_or_create( sensor.DOMAIN, DOMAIN, - f"{DOMAIN} {MAC_ADDR} Upload", + f"{DOMAIN} {ROUTER_MAC_ADDR} Upload", suggested_object_id=obj_entity_id, config_entry=config_entry, disabled_by=None, @@ -409,6 +407,31 @@ async def test_unique_id_migration(hass: HomeAssistant, connect) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - migr_entity = entity_reg.async_get(f"{sensor.DOMAIN}.{obj_entity_id}") + migr_entity = entity_registry.async_get(f"{sensor.DOMAIN}.{obj_entity_id}") assert migr_entity is not None - assert migr_entity.unique_id == slugify(f"{MAC_ADDR}_sensor_tx_bytes") + assert migr_entity.unique_id == slugify(f"{ROUTER_MAC_ADDR}_sensor_tx_bytes") + + +async def test_decorator_errors( + hass: HomeAssistant, connect_legacy, mock_available_temps +) -> None: + """Test AsusWRT sensors are unavailable on decorator type check error.""" + sensors = [*SENSORS_BYTES, *SENSORS_TEMPERATURES] + config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA_TELNET, sensors) + config_entry.add_to_hass(hass) + + mock_available_temps[1] = True + connect_legacy.return_value.async_get_bytes_total.return_value = "bad_response" + connect_legacy.return_value.async_get_temperature.return_value = "bad_response" + + # initial devices setup + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + for sensor_name in sensors: + assert ( + hass.states.get(f"{sensor_prefix}_{slugify(sensor_name)}").state + == STATE_UNAVAILABLE + ) diff --git a/tests/components/atag/test_climate.py b/tests/components/atag/test_climate.py index 485ad0308bc..da5eefa589b 100644 --- a/tests/components/atag/test_climate.py +++ b/tests/components/atag/test_climate.py @@ -33,11 +33,12 @@ CLIMATE_ID = f"{Platform.CLIMATE}.{DOMAIN}" async def test_climate( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test the creation and values of Atag climate device.""" await init_integration(hass, aioclient_mock) - entity_registry = er.async_get(hass) assert entity_registry.async_is_registered(CLIMATE_ID) entity = entity_registry.async_get(CLIMATE_ID) diff --git a/tests/components/atag/test_sensors.py b/tests/components/atag/test_sensors.py index 58a687512e2..358fe27804a 100644 --- a/tests/components/atag/test_sensors.py +++ b/tests/components/atag/test_sensors.py @@ -9,14 +9,15 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_sensors( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test the creation of ATAG sensors.""" entry = await init_integration(hass, aioclient_mock) - registry = er.async_get(hass) for item in SENSORS: sensor_id = "_".join(f"sensor.{item}".lower().split()) - assert registry.async_is_registered(sensor_id) - entry = registry.async_get(sensor_id) + assert entity_registry.async_is_registered(sensor_id) + entry = entity_registry.async_get(sensor_id) assert entry.unique_id in [f"{UID}-{v}" for v in SENSORS.values()] diff --git a/tests/components/atag/test_water_heater.py b/tests/components/atag/test_water_heater.py index 428ff890116..49425972d88 100644 --- a/tests/components/atag/test_water_heater.py +++ b/tests/components/atag/test_water_heater.py @@ -18,15 +18,16 @@ WATER_HEATER_ID = f"{Platform.WATER_HEATER}.{DOMAIN}" async def test_water_heater( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test the creation of Atag water heater.""" with patch("pyatag.entities.DHW.status"): entry = await init_integration(hass, aioclient_mock) - registry = er.async_get(hass) - assert registry.async_is_registered(WATER_HEATER_ID) - entry = registry.async_get(WATER_HEATER_ID) + assert entity_registry.async_is_registered(WATER_HEATER_ID) + entry = entity_registry.async_get(WATER_HEATER_ID) assert entry.unique_id == f"{UID}-{Platform.WATER_HEATER}" diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py index 50cac4445ab..72352477b4a 100644 --- a/tests/components/august/test_binary_sensor.py +++ b/tests/components/august/test_binary_sensor.py @@ -293,13 +293,13 @@ async def test_doorbell_update_via_pubnub(hass: HomeAssistant) -> None: assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF -async def test_doorbell_device_registry(hass: HomeAssistant) -> None: +async def test_doorbell_device_registry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test creation of a lock with doorsense and bridge ands up in the registry.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json") await _create_august_with_devices(hass, [doorbell_one]) - device_registry = dr.async_get(hass) - reg_device = device_registry.async_get_device(identifiers={("august", "tmt100")}) assert reg_device.model == "hydra1" assert reg_device.name == "tmt100 Name" diff --git a/tests/components/august/test_init.py b/tests/components/august/test_init.py index 36a7f73f8a8..55bc44c6f27 100644 --- a/tests/components/august/test_init.py +++ b/tests/components/august/test_init.py @@ -19,7 +19,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from .mocks import ( @@ -400,16 +399,17 @@ async def remove_device(ws_client, device_id, config_entry_id): async def test_device_remove_devices( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test we can only remove a device that no longer exists.""" assert await async_setup_component(hass, "config", {}) august_operative_lock = await _mock_operative_august_lock_detail(hass) config_entry = await _create_august_with_devices(hass, [august_operative_lock]) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities["lock.a6697750d607098bae8d6baa11ef8063_name"] + entity = entity_registry.entities["lock.a6697750d607098bae8d6baa11ef8063_name"] - device_registry = dr.async_get(hass) device_entry = device_registry.async_get(entity.device_id) assert ( await remove_device( diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index d1e60951c20..bc2cd23b23d 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -35,13 +35,13 @@ from .mocks import ( from tests.common import async_fire_time_changed -async def test_lock_device_registry(hass: HomeAssistant) -> None: +async def test_lock_device_registry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test creation of a lock with doorsense and bridge ands up in the registry.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) await _create_august_with_devices(hass, [lock_one]) - device_registry = dr.async_get(hass) - reg_device = device_registry.async_get_device( identifiers={("august", "online_with_doorsense")} ) @@ -106,7 +106,9 @@ async def test_state_jammed(hass: HomeAssistant) -> None: assert lock_online_with_doorsense_name.state == STATE_JAMMED -async def test_one_lock_operation(hass: HomeAssistant) -> None: +async def test_one_lock_operation( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test creation of a lock with doorsense and bridge.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) await _create_august_with_devices(hass, [lock_one]) @@ -141,7 +143,6 @@ async def test_one_lock_operation(hass: HomeAssistant) -> None: assert lock_online_with_doorsense_name.state == STATE_LOCKED # No activity means it will be unavailable until the activity feed has data - entity_registry = er.async_get(hass) lock_operator_sensor = entity_registry.async_get( "sensor.online_with_doorsense_name_operator" ) @@ -152,7 +153,9 @@ async def test_one_lock_operation(hass: HomeAssistant) -> None: ) -async def test_one_lock_operation_pubnub_connected(hass: HomeAssistant) -> None: +async def test_one_lock_operation_pubnub_connected( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test lock and unlock operations are async when pubnub is connected.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) assert lock_one.pubsub_channel == "pubsub" @@ -217,7 +220,6 @@ async def test_one_lock_operation_pubnub_connected(hass: HomeAssistant) -> None: assert lock_online_with_doorsense_name.state == STATE_LOCKED # No activity means it will be unavailable until the activity feed has data - entity_registry = er.async_get(hass) lock_operator_sensor = entity_registry.async_get( "sensor.online_with_doorsense_name_operator" ) diff --git a/tests/components/august/test_sensor.py b/tests/components/august/test_sensor.py index ae7d46dcb22..d71d22064fc 100644 --- a/tests/components/august/test_sensor.py +++ b/tests/components/august/test_sensor.py @@ -36,11 +36,12 @@ async def test_create_doorbell(hass: HomeAssistant) -> None: ) -async def test_create_doorbell_offline(hass: HomeAssistant) -> None: +async def test_create_doorbell_offline( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test creation of a doorbell that is offline.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json") await _create_august_with_devices(hass, [doorbell_one]) - entity_registry = er.async_get(hass) sensor_tmt100_name_battery = hass.states.get("sensor.tmt100_name_battery") assert sensor_tmt100_name_battery.state == "81" @@ -62,11 +63,12 @@ async def test_create_doorbell_hardwired(hass: HomeAssistant) -> None: assert sensor_tmt100_name_battery is None -async def test_create_lock_with_linked_keypad(hass: HomeAssistant) -> None: +async def test_create_lock_with_linked_keypad( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test creation of a lock with a linked keypad that both have a battery.""" lock_one = await _mock_lock_from_fixture(hass, "get_lock.doorsense_init.json") await _create_august_with_devices(hass, [lock_one]) - entity_registry = er.async_get(hass) sensor_a6697750d607098bae8d6baa11ef8063_name_battery = hass.states.get( "sensor.a6697750d607098bae8d6baa11ef8063_name_battery" @@ -92,11 +94,12 @@ async def test_create_lock_with_linked_keypad(hass: HomeAssistant) -> None: assert entry.unique_id == "5bc65c24e6ef2a263e1450a8_linked_keypad_battery" -async def test_create_lock_with_low_battery_linked_keypad(hass: HomeAssistant) -> None: +async def test_create_lock_with_low_battery_linked_keypad( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test creation of a lock with a linked keypad that both have a battery.""" lock_one = await _mock_lock_from_fixture(hass, "get_lock.low_keypad_battery.json") await _create_august_with_devices(hass, [lock_one]) - entity_registry = er.async_get(hass) sensor_a6697750d607098bae8d6baa11ef8063_name_battery = hass.states.get( "sensor.a6697750d607098bae8d6baa11ef8063_name_battery" @@ -135,7 +138,9 @@ async def test_create_lock_with_low_battery_linked_keypad(hass: HomeAssistant) - ) -async def test_lock_operator_bluetooth(hass: HomeAssistant) -> None: +async def test_lock_operator_bluetooth( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test operation of a lock with doorsense and bridge.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) @@ -144,7 +149,6 @@ async def test_lock_operator_bluetooth(hass: HomeAssistant) -> None: ) await _create_august_with_devices(hass, [lock_one], activities=activities) - entity_registry = er.async_get(hass) lock_operator_sensor = entity_registry.async_get( "sensor.online_with_doorsense_name_operator" ) @@ -160,7 +164,9 @@ async def test_lock_operator_bluetooth(hass: HomeAssistant) -> None: assert state.attributes["method"] == "mobile" -async def test_lock_operator_keypad(hass: HomeAssistant) -> None: +async def test_lock_operator_keypad( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test operation of a lock with doorsense and bridge.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) @@ -169,7 +175,6 @@ async def test_lock_operator_keypad(hass: HomeAssistant) -> None: ) await _create_august_with_devices(hass, [lock_one], activities=activities) - entity_registry = er.async_get(hass) lock_operator_sensor = entity_registry.async_get( "sensor.online_with_doorsense_name_operator" ) @@ -185,14 +190,15 @@ async def test_lock_operator_keypad(hass: HomeAssistant) -> None: assert state.attributes["method"] == "keypad" -async def test_lock_operator_remote(hass: HomeAssistant) -> None: +async def test_lock_operator_remote( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test operation of a lock with doorsense and bridge.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) activities = await _mock_activities_from_fixture(hass, "get_activity.lock.json") await _create_august_with_devices(hass, [lock_one], activities=activities) - entity_registry = er.async_get(hass) lock_operator_sensor = entity_registry.async_get( "sensor.online_with_doorsense_name_operator" ) @@ -208,7 +214,9 @@ async def test_lock_operator_remote(hass: HomeAssistant) -> None: assert state.attributes["method"] == "remote" -async def test_lock_operator_manual(hass: HomeAssistant) -> None: +async def test_lock_operator_manual( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test operation of a lock with doorsense and bridge.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) @@ -217,7 +225,6 @@ async def test_lock_operator_manual(hass: HomeAssistant) -> None: ) await _create_august_with_devices(hass, [lock_one], activities=activities) - entity_registry = er.async_get(hass) lock_operator_sensor = entity_registry.async_get( "sensor.online_with_doorsense_name_operator" ) @@ -232,7 +239,9 @@ async def test_lock_operator_manual(hass: HomeAssistant) -> None: assert state.attributes["method"] == "manual" -async def test_lock_operator_autorelock(hass: HomeAssistant) -> None: +async def test_lock_operator_autorelock( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test operation of a lock with doorsense and bridge.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) @@ -241,7 +250,6 @@ async def test_lock_operator_autorelock(hass: HomeAssistant) -> None: ) await _create_august_with_devices(hass, [lock_one], activities=activities) - entity_registry = er.async_get(hass) lock_operator_sensor = entity_registry.async_get( "sensor.online_with_doorsense_name_operator" ) @@ -257,7 +265,9 @@ async def test_lock_operator_autorelock(hass: HomeAssistant) -> None: assert state.attributes["method"] == "autorelock" -async def test_unlock_operator_manual(hass: HomeAssistant) -> None: +async def test_unlock_operator_manual( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test operation of a lock manually.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) @@ -266,7 +276,6 @@ async def test_unlock_operator_manual(hass: HomeAssistant) -> None: ) await _create_august_with_devices(hass, [lock_one], activities=activities) - entity_registry = er.async_get(hass) lock_operator_sensor = entity_registry.async_get( "sensor.online_with_doorsense_name_operator" ) @@ -282,7 +291,9 @@ async def test_unlock_operator_manual(hass: HomeAssistant) -> None: assert state.attributes["method"] == "manual" -async def test_unlock_operator_tag(hass: HomeAssistant) -> None: +async def test_unlock_operator_tag( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test operation of a lock with a tag.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) @@ -291,7 +302,6 @@ async def test_unlock_operator_tag(hass: HomeAssistant) -> None: ) await _create_august_with_devices(hass, [lock_one], activities=activities) - entity_registry = er.async_get(hass) lock_operator_sensor = entity_registry.async_get( "sensor.online_with_doorsense_name_operator" ) diff --git a/tests/components/aurora_abb_powerone/test_config_flow.py b/tests/components/aurora_abb_powerone/test_config_flow.py index b30da3ce348..d156dce2154 100644 --- a/tests/components/aurora_abb_powerone/test_config_flow.py +++ b/tests/components/aurora_abb_powerone/test_config_flow.py @@ -1,5 +1,4 @@ """Test the Aurora ABB PowerOne Solar PV config flow.""" -from logging import INFO from unittest.mock import patch from aurorapy.client import AuroraError, AuroraTimeoutError @@ -49,9 +48,6 @@ async def test_form(hass: HomeAssistant) -> None: ), patch( "aurorapy.client.AuroraSerialClient.firmware", return_value="1.234", - ), patch( - "homeassistant.components.aurora_abb_powerone.config_flow._LOGGER.getEffectiveLevel", - return_value=INFO, ) as mock_setup, patch( "homeassistant.components.aurora_abb_powerone.async_setup_entry", return_value=True, diff --git a/tests/components/aurora_abb_powerone/test_init.py b/tests/components/aurora_abb_powerone/test_init.py index f88cab0cb46..92b448d8645 100644 --- a/tests/components/aurora_abb_powerone/test_init.py +++ b/tests/components/aurora_abb_powerone/test_init.py @@ -18,9 +18,6 @@ async def test_unload_entry(hass: HomeAssistant) -> None: """Test unloading the aurora_abb_powerone entry.""" with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( - "homeassistant.components.aurora_abb_powerone.sensor.AuroraSensor.update", - return_value=None, - ), patch( "aurorapy.client.AuroraSerialClient.serial_number", return_value="9876543", ), patch( diff --git a/tests/components/aurora_abb_powerone/test_sensor.py b/tests/components/aurora_abb_powerone/test_sensor.py index 8fbe29f9979..61521c49b79 100644 --- a/tests/components/aurora_abb_powerone/test_sensor.py +++ b/tests/components/aurora_abb_powerone/test_sensor.py @@ -1,8 +1,8 @@ """Test the Aurora ABB PowerOne Solar PV sensors.""" -from datetime import timedelta from unittest.mock import patch from aurorapy.client import AuroraError, AuroraTimeoutError +from freezegun.api import FrozenDateTimeFactory from homeassistant.components.aurora_abb_powerone.const import ( ATTR_DEVICE_NAME, @@ -11,10 +11,10 @@ from homeassistant.components.aurora_abb_powerone.const import ( ATTR_SERIAL_NUMBER, DEFAULT_INTEGRATION_TITLE, DOMAIN, + SCAN_INTERVAL, ) from homeassistant.const import CONF_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -95,14 +95,16 @@ async def test_sensors(hass: HomeAssistant) -> None: assert energy.state == "12.35" -async def test_sensor_dark(hass: HomeAssistant) -> None: +async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test that darkness (no comms) is handled correctly.""" mock_entry = _mock_config_entry() - utcnow = dt_util.utcnow() # sun is up with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", side_effect=_simulated_returns + ), patch( + "aurorapy.client.AuroraSerialClient.cumulated_energy", + side_effect=_simulated_returns, ), patch( "aurorapy.client.AuroraSerialClient.serial_number", return_value="9876543", @@ -128,16 +130,24 @@ async def test_sensor_dark(hass: HomeAssistant) -> None: with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", side_effect=AuroraTimeoutError("No response after 10 seconds"), + ), patch( + "aurorapy.client.AuroraSerialClient.cumulated_energy", + side_effect=AuroraTimeoutError("No response after 3 tries"), ): - async_fire_time_changed(hass, utcnow + timedelta(seconds=60)) + freezer.tick(SCAN_INTERVAL * 2) + async_fire_time_changed(hass) await hass.async_block_till_done() - power = hass.states.get("sensor.mydevicename_power_output") + power = hass.states.get("sensor.mydevicename_total_energy") assert power.state == "unknown" # sun rose again with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", side_effect=_simulated_returns + ), patch( + "aurorapy.client.AuroraSerialClient.cumulated_energy", + side_effect=_simulated_returns, ): - async_fire_time_changed(hass, utcnow + timedelta(seconds=60)) + freezer.tick(SCAN_INTERVAL * 4) + async_fire_time_changed(hass) await hass.async_block_till_done() power = hass.states.get("sensor.mydevicename_power_output") assert power is not None @@ -146,8 +156,12 @@ async def test_sensor_dark(hass: HomeAssistant) -> None: with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", side_effect=AuroraTimeoutError("No response after 10 seconds"), + ), patch( + "aurorapy.client.AuroraSerialClient.cumulated_energy", + side_effect=AuroraError("No response after 10 seconds"), ): - async_fire_time_changed(hass, utcnow + timedelta(seconds=60)) + freezer.tick(SCAN_INTERVAL * 6) + async_fire_time_changed(hass) await hass.async_block_till_done() power = hass.states.get("sensor.mydevicename_power_output") assert power.state == "unknown" # should this be 'available'? @@ -160,7 +174,7 @@ async def test_sensor_unknown_error(hass: HomeAssistant) -> None: with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", side_effect=AuroraError("another error"), - ): + ), patch("serial.Serial.isOpen", return_value=True): 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/auth/__init__.py b/tests/components/auth/__init__.py index 7ce65964086..8b731934913 100644 --- a/tests/components/auth/__init__.py +++ b/tests/components/auth/__init__.py @@ -1,8 +1,13 @@ """Tests for the auth component.""" +from typing import Any + from homeassistant import auth +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import ensure_auth_manager_loaded +from tests.test_util import mock_real_ip +from tests.typing import ClientSessionGenerator BASE_CONFIG = [ { @@ -18,11 +23,12 @@ EMPTY_CONFIG = [] async def async_setup_auth( - hass, - aiohttp_client, - provider_configs=BASE_CONFIG, + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + provider_configs: list[dict[str, Any]] = BASE_CONFIG, module_configs=EMPTY_CONFIG, - setup_api=False, + setup_api: bool = False, + custom_ip: str | None = None, ): """Set up authentication and create an HTTP client.""" hass.auth = await auth.auth_manager_from_config( @@ -32,4 +38,6 @@ async def async_setup_auth( await async_setup_component(hass, "auth", {}) if setup_api: await async_setup_component(hass, "api", {}) + if custom_ip: + mock_real_ip(hass.http.app)(custom_ip) return await aiohttp_client(hass.http.app) diff --git a/tests/components/auth/test_login_flow.py b/tests/components/auth/test_login_flow.py index b44d8fb4a11..639bbb9a9cb 100644 --- a/tests/components/auth/test_login_flow.py +++ b/tests/components/auth/test_login_flow.py @@ -1,25 +1,141 @@ """Tests for the login flow.""" +from collections.abc import Callable from http import HTTPStatus +from typing import Any from unittest.mock import patch -from homeassistant.core import HomeAssistant +import pytest -from . import async_setup_auth +from homeassistant.auth.models import User +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import BASE_CONFIG, async_setup_auth from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI from tests.typing import ClientSessionGenerator +_TRUSTED_NETWORKS_CONFIG = { + "type": "trusted_networks", + "trusted_networks": ["192.168.0.1"], + "trusted_users": { + "192.168.0.1": [ + "a1ab982744b64757bf80515589258924", + {"group": "system-group"}, + ] + }, +} + +@pytest.mark.parametrize( + ("provider_configs", "ip", "expected"), + [ + ( + BASE_CONFIG, + None, + [{"name": "Example", "type": "insecure_example", "id": None}], + ), + ( + [_TRUSTED_NETWORKS_CONFIG], + None, + [], + ), + ( + [_TRUSTED_NETWORKS_CONFIG], + "192.168.0.1", + [{"name": "Trusted Networks", "type": "trusted_networks", "id": None}], + ), + ], +) async def test_fetch_auth_providers( - hass: HomeAssistant, aiohttp_client: ClientSessionGenerator + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + provider_configs: list[dict[str, Any]], + ip: str | None, + expected: list[dict[str, Any]], ) -> None: """Test fetching auth providers.""" - client = await async_setup_auth(hass, aiohttp_client) + client = await async_setup_auth( + hass, aiohttp_client, provider_configs, custom_ip=ip + ) resp = await client.get("/auth/providers") assert resp.status == HTTPStatus.OK - assert await resp.json() == [ - {"name": "Example", "type": "insecure_example", "id": None} - ] + assert await resp.json() == expected + + +async def _test_fetch_auth_providers_home_assistant( + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + ip: str, + additional_expected_fn: Callable[[User], dict[str, Any]], +) -> None: + """Test fetching auth providers for homeassistant auth provider.""" + client = await async_setup_auth( + hass, aiohttp_client, [{"type": "homeassistant"}], custom_ip=ip + ) + + provider = hass.auth.auth_providers[0] + credentials = await provider.async_get_or_create_credentials({"username": "hello"}) + user = await hass.auth.async_get_or_create_user(credentials) + + expected = { + "name": "Home Assistant Local", + "type": "homeassistant", + "id": None, + **additional_expected_fn(user), + } + + resp = await client.get("/auth/providers") + assert resp.status == HTTPStatus.OK + assert await resp.json() == [expected] + + +@pytest.mark.parametrize( + "ip", + [ + "192.168.0.10", + "::ffff:192.168.0.10", + "1.2.3.4", + "2001:db8::1", + ], +) +async def test_fetch_auth_providers_home_assistant_person_not_loaded( + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + ip: str, +) -> None: + """Test fetching auth providers for homeassistant auth provider, where person integration is not loaded.""" + await _test_fetch_auth_providers_home_assistant( + hass, aiohttp_client, ip, lambda _: {} + ) + + +@pytest.mark.parametrize( + ("ip", "is_local"), + [ + ("192.168.0.10", True), + ("::ffff:192.168.0.10", True), + ("1.2.3.4", False), + ("2001:db8::1", False), + ], +) +async def test_fetch_auth_providers_home_assistant_person_loaded( + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + ip: str, + is_local: bool, +) -> None: + """Test fetching auth providers for homeassistant auth provider, where person integration is loaded.""" + domain = "person" + config = {domain: {"id": "1234", "name": "test person"}} + assert await async_setup_component(hass, domain, config) + + await _test_fetch_auth_providers_home_assistant( + hass, + aiohttp_client, + ip, + lambda user: {"users": {user.id: user.name}} if is_local else {}, + ) async def test_fetch_auth_providers_onboarding( diff --git a/tests/components/awair/test_init.py b/tests/components/awair/test_init.py index 00a5a422a4e..f3a4bb636e6 100644 --- a/tests/components/awair/test_init.py +++ b/tests/components/awair/test_init.py @@ -9,14 +9,13 @@ from .const import LOCAL_CONFIG, LOCAL_UNIQUE_ID async def test_local_awair_sensors( - hass: HomeAssistant, local_devices, local_data + hass: HomeAssistant, local_devices, local_data, device_registry: dr.DeviceRegistry ) -> None: """Test expected sensors on a local Awair.""" fixtures = [local_devices, local_data] entry = await setup_awair(hass, fixtures, LOCAL_UNIQUE_ID, LOCAL_CONFIG) - dev_reg = dr.async_get(hass) - device_entry = dr.async_entries_for_config_entry(dev_reg, entry.entry_id)[0] + device_entry = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] assert device_entry.name == "Mock Title" @@ -24,5 +23,5 @@ async def test_local_awair_sensors( hass.config_entries.async_update_entry(entry, title="Hello World") await hass.async_block_till_done() - device_entry = dev_reg.async_get(device_entry.id) + device_entry = device_registry.async_get(device_entry.id) assert device_entry.name == "Hello World" diff --git a/tests/components/awair/test_sensor.py b/tests/components/awair/test_sensor.py index 24bbb40d9cf..849ac59a22f 100644 --- a/tests/components/awair/test_sensor.py +++ b/tests/components/awair/test_sensor.py @@ -65,17 +65,20 @@ def assert_expected_properties( async def test_awair_gen1_sensors( - hass: HomeAssistant, user, cloud_devices, gen1_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + user, + cloud_devices, + gen1_data, ) -> None: """Test expected sensors on a 1st gen Awair.""" fixtures = [user, cloud_devices, gen1_data] await setup_awair(hass, fixtures, CLOUD_UNIQUE_ID, CLOUD_CONFIG) - registry = er.async_get(hass) assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_score", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "88", @@ -84,7 +87,7 @@ async def test_awair_gen1_sensors( assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_temperature", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_TEMP].unique_id_tag}", "21.8", @@ -93,7 +96,7 @@ async def test_awair_gen1_sensors( assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_humidity", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_HUMID].unique_id_tag}", "41.59", @@ -102,7 +105,7 @@ async def test_awair_gen1_sensors( assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_carbon_dioxide", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_CO2].unique_id_tag}", "654.0", @@ -114,7 +117,7 @@ async def test_awair_gen1_sensors( assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_vocs", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_VOC].unique_id_tag}", "366", @@ -126,7 +129,7 @@ async def test_awair_gen1_sensors( assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_pm2_5", # gen1 unique_id should be awair_12345-DUST, which matches old integration behavior f"{AWAIR_UUID}_DUST", @@ -139,7 +142,7 @@ async def test_awair_gen1_sensors( assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_pm10", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_PM10].unique_id_tag}", "14.3", @@ -159,17 +162,20 @@ async def test_awair_gen1_sensors( async def test_awair_gen2_sensors( - hass: HomeAssistant, user, cloud_devices, gen2_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + user, + cloud_devices, + gen2_data, ) -> None: """Test expected sensors on a 2nd gen Awair.""" fixtures = [user, cloud_devices, gen2_data] await setup_awair(hass, fixtures, CLOUD_UNIQUE_ID, CLOUD_CONFIG) - registry = er.async_get(hass) assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_score", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "97", @@ -178,7 +184,7 @@ async def test_awair_gen2_sensors( assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_pm2_5", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_PM25].unique_id_tag}", "2.0", @@ -194,17 +200,16 @@ async def test_awair_gen2_sensors( async def test_local_awair_sensors( - hass: HomeAssistant, local_devices, local_data + hass: HomeAssistant, entity_registry: er.EntityRegistry, local_devices, local_data ) -> None: """Test expected sensors on a local Awair.""" fixtures = [local_devices, local_data] await setup_awair(hass, fixtures, LOCAL_UNIQUE_ID, LOCAL_CONFIG) - registry = er.async_get(hass) assert_expected_properties( hass, - registry, + entity_registry, "sensor.mock_title_score", f"{local_devices['device_uuid']}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "94", @@ -213,17 +218,20 @@ async def test_local_awair_sensors( async def test_awair_mint_sensors( - hass: HomeAssistant, user, cloud_devices, mint_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + user, + cloud_devices, + mint_data, ) -> None: """Test expected sensors on an Awair mint.""" fixtures = [user, cloud_devices, mint_data] await setup_awair(hass, fixtures, CLOUD_UNIQUE_ID, CLOUD_CONFIG) - registry = er.async_get(hass) assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_score", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "98", @@ -232,7 +240,7 @@ async def test_awair_mint_sensors( assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_pm2_5", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_PM25].unique_id_tag}", "1.0", @@ -244,7 +252,7 @@ async def test_awair_mint_sensors( assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_illuminance", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_LUX].unique_id_tag}", "441.7", @@ -256,17 +264,20 @@ async def test_awair_mint_sensors( async def test_awair_glow_sensors( - hass: HomeAssistant, user, cloud_devices, glow_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + user, + cloud_devices, + glow_data, ) -> None: """Test expected sensors on an Awair glow.""" fixtures = [user, cloud_devices, glow_data] await setup_awair(hass, fixtures, CLOUD_UNIQUE_ID, CLOUD_CONFIG) - registry = er.async_get(hass) assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_score", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "93", @@ -278,17 +289,20 @@ async def test_awair_glow_sensors( async def test_awair_omni_sensors( - hass: HomeAssistant, user, cloud_devices, omni_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + user, + cloud_devices, + omni_data, ) -> None: """Test expected sensors on an Awair omni.""" fixtures = [user, cloud_devices, omni_data] await setup_awair(hass, fixtures, CLOUD_UNIQUE_ID, CLOUD_CONFIG) - registry = er.async_get(hass) assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_score", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "99", @@ -297,7 +311,7 @@ async def test_awair_omni_sensors( assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_sound_level", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SPL_A].unique_id_tag}", "47.0", @@ -306,7 +320,7 @@ async def test_awair_omni_sensors( assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_illuminance", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_LUX].unique_id_tag}", "804.9", @@ -335,17 +349,21 @@ async def test_awair_offline( async def test_awair_unavailable( - hass: HomeAssistant, user, cloud_devices, gen1_data, awair_offline + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + user, + cloud_devices, + gen1_data, + awair_offline, ) -> None: """Test expected behavior when an Awair becomes offline later.""" fixtures = [user, cloud_devices, gen1_data] await setup_awair(hass, fixtures, CLOUD_UNIQUE_ID, CLOUD_CONFIG) - registry = er.async_get(hass) assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_score", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "88", @@ -356,7 +374,7 @@ async def test_awair_unavailable( await async_update_entity(hass, "sensor.living_room_score") assert_expected_properties( hass, - registry, + entity_registry, "sensor.living_room_score", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", STATE_UNAVAILABLE, diff --git a/tests/components/axis/test_device.py b/tests/components/axis/test_device.py index ff7ff343a06..bc5bd13c284 100644 --- a/tests/components/axis/test_device.py +++ b/tests/components/axis/test_device.py @@ -41,7 +41,11 @@ def hass_mock_forward_entry_setup(hass): async def test_device_setup( - hass: HomeAssistant, forward_entry_setup, config, setup_config_entry + hass: HomeAssistant, + forward_entry_setup, + config, + setup_config_entry, + device_registry: dr.DeviceRegistry, ) -> None: """Successful setup.""" device = hass.data[AXIS_DOMAIN][setup_config_entry.entry_id] @@ -62,7 +66,6 @@ async def test_device_setup( assert device.name == config[CONF_NAME] assert device.unique_id == FORMATTED_MAC - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device( identifiers={(AXIS_DOMAIN, device.unique_id)} ) diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 9d3b9889cd3..e23f86e545b 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -92,7 +92,8 @@ async def test_load_backups(hass: HomeAssistant) -> None: "date": TEST_BACKUP.date, }, ), patch( - "pathlib.Path.stat", return_value=MagicMock(st_size=TEST_BACKUP.size) + "pathlib.Path.stat", + return_value=MagicMock(st_size=TEST_BACKUP.size), ): await manager.load_backups() backups = await manager.get_backups() diff --git a/tests/components/balboa/test_binary_sensor.py b/tests/components/balboa/test_binary_sensor.py index e97887b154a..ee5f2bc353c 100644 --- a/tests/components/balboa/test_binary_sensor.py +++ b/tests/components/balboa/test_binary_sensor.py @@ -16,7 +16,7 @@ async def test_filters( ) -> None: """Test spa filters.""" for num in (1, 2): - sensor = f"{ENTITY_BINARY_SENSOR}filter{num}" + sensor = f"{ENTITY_BINARY_SENSOR}filter_cycle_{num}" state = hass.states.get(sensor) assert state.state == STATE_OFF @@ -33,7 +33,7 @@ async def test_circ_pump( hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry ) -> None: """Test spa circ pump.""" - sensor = f"{ENTITY_BINARY_SENSOR}circ_pump" + sensor = f"{ENTITY_BINARY_SENSOR}circulation_pump" state = hass.states.get(sensor) assert state.state == STATE_OFF diff --git a/tests/components/balboa/test_climate.py b/tests/components/balboa/test_climate.py index 4967bcdfa38..90ef6c75e5f 100644 --- a/tests/components/balboa/test_climate.py +++ b/tests/components/balboa/test_climate.py @@ -38,7 +38,7 @@ HVAC_SETTINGS = [ HVACMode.AUTO, ] -ENTITY_CLIMATE = "climate.fakespa_climate" +ENTITY_CLIMATE = "climate.fakespa" async def test_spa_defaults( diff --git a/tests/components/binary_sensor/test_init.py b/tests/components/binary_sensor/test_init.py index a35a6c906df..437a2e1efa6 100644 --- a/tests/components/binary_sensor/test_init.py +++ b/tests/components/binary_sensor/test_init.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components import binary_sensor from homeassistant.config_entries import ConfigEntry, ConfigFlow -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -18,6 +18,7 @@ from tests.common import ( mock_integration, mock_platform, ) +from tests.testing_config.custom_components.test.binary_sensor import MockBinarySensor TEST_DOMAIN = "test" @@ -126,3 +127,70 @@ async def test_name(hass: HomeAssistant) -> None: state = hass.states.get(entity4.entity_id) assert state.attributes == {"device_class": "battery", "friendly_name": "Battery"} + + +async def test_entity_category_config_raises_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error is raised when entity category is set to config.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup( + config_entry, binary_sensor.DOMAIN + ) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + ), + ) + + description1 = binary_sensor.BinarySensorEntityDescription( + "diagnostic", entity_category=EntityCategory.DIAGNOSTIC + ) + entity1 = MockBinarySensor() + entity1.entity_description = description1 + entity1.entity_id = "binary_sensor.test1" + + description2 = binary_sensor.BinarySensorEntityDescription( + "config", entity_category=EntityCategory.CONFIG + ) + entity2 = MockBinarySensor() + entity2.entity_description = description2 + entity2.entity_id = "binary_sensor.test2" + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test stt platform via config entry.""" + async_add_entities([entity1, entity2]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{binary_sensor.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() + + state1 = hass.states.get("binary_sensor.test1") + assert state1 is not None + state2 = hass.states.get("binary_sensor.test2") + assert state2 is None + assert ( + "Entity binary_sensor.test2 cannot be added as the entity category is set to config" + in caplog.text + ) diff --git a/tests/components/blebox/test_binary_sensor.py b/tests/components/blebox/test_binary_sensor.py index 25ab8cab8cb..3c05a425b12 100644 --- a/tests/components/blebox/test_binary_sensor.py +++ b/tests/components/blebox/test_binary_sensor.py @@ -28,7 +28,9 @@ def airsensor_fixture() -> tuple[AsyncMock, str]: return feature, "binary_sensor.windrainsensor_0_rain" -async def test_init(rainsensor: AsyncMock, hass: HomeAssistant) -> None: +async def test_init( + rainsensor: AsyncMock, device_registry: dr.DeviceRegistry, hass: HomeAssistant +) -> None: """Test binary_sensor initialisation.""" _, entity_id = rainsensor entry = await async_setup_entity(hass, entity_id) @@ -40,7 +42,6 @@ async def test_init(rainsensor: AsyncMock, hass: HomeAssistant) -> None: assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.MOISTURE assert state.state == STATE_ON - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My rain sensor" diff --git a/tests/components/blebox/test_climate.py b/tests/components/blebox/test_climate.py index 2e6e5de4573..6ea6d995900 100644 --- a/tests/components/blebox/test_climate.py +++ b/tests/components/blebox/test_climate.py @@ -76,7 +76,9 @@ def thermobox_fixture(): return (feature, "climate.thermobox_thermostat") -async def test_init(saunabox, hass: HomeAssistant) -> None: +async def test_init( + saunabox, hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test default state.""" _, entity_id = saunabox @@ -102,7 +104,6 @@ async def test_init(saunabox, hass: HomeAssistant) -> None: assert state.state == STATE_UNKNOWN - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My sauna" diff --git a/tests/components/blebox/test_cover.py b/tests/components/blebox/test_cover.py index cbf8f5e589b..8691c886faa 100644 --- a/tests/components/blebox/test_cover.py +++ b/tests/components/blebox/test_cover.py @@ -98,7 +98,9 @@ def gate_fixture(): return (feature, "cover.gatecontroller_position") -async def test_init_gatecontroller(gatecontroller, hass: HomeAssistant) -> None: +async def test_init_gatecontroller( + gatecontroller, hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test gateController default state.""" _, entity_id = gatecontroller @@ -118,7 +120,6 @@ async def test_init_gatecontroller(gatecontroller, hass: HomeAssistant) -> None: assert ATTR_CURRENT_POSITION not in state.attributes assert state.state == STATE_UNKNOWN - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My gate controller" @@ -128,7 +129,9 @@ async def test_init_gatecontroller(gatecontroller, hass: HomeAssistant) -> None: assert device.sw_version == "1.23" -async def test_init_shutterbox(shutterbox, hass: HomeAssistant) -> None: +async def test_init_shutterbox( + shutterbox, hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test gateBox default state.""" _, entity_id = shutterbox @@ -148,7 +151,6 @@ async def test_init_shutterbox(shutterbox, hass: HomeAssistant) -> None: assert ATTR_CURRENT_POSITION not in state.attributes assert state.state == STATE_UNKNOWN - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My shutter" @@ -158,7 +160,9 @@ async def test_init_shutterbox(shutterbox, hass: HomeAssistant) -> None: assert device.sw_version == "1.23" -async def test_init_gatebox(gatebox, hass: HomeAssistant) -> None: +async def test_init_gatebox( + gatebox, hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test cover default state.""" _, entity_id = gatebox @@ -180,7 +184,6 @@ async def test_init_gatebox(gatebox, hass: HomeAssistant) -> None: assert ATTR_CURRENT_POSITION not in state.attributes assert state.state == STATE_UNKNOWN - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My gatebox" diff --git a/tests/components/blebox/test_light.py b/tests/components/blebox/test_light.py index e2184df9820..47f38ba815b 100644 --- a/tests/components/blebox/test_light.py +++ b/tests/components/blebox/test_light.py @@ -50,7 +50,9 @@ def dimmer_fixture(): return (feature, "light.dimmerbox_brightness") -async def test_dimmer_init(dimmer, hass: HomeAssistant) -> None: +async def test_dimmer_init( + dimmer, hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test cover default state.""" _, entity_id = dimmer @@ -66,7 +68,6 @@ async def test_dimmer_init(dimmer, hass: HomeAssistant) -> None: assert state.attributes[ATTR_BRIGHTNESS] == 65 assert state.state == STATE_ON - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My dimmer" @@ -223,7 +224,9 @@ def wlightboxs_fixture(): return (feature, "light.wlightboxs_color") -async def test_wlightbox_s_init(wlightbox_s, hass: HomeAssistant) -> None: +async def test_wlightbox_s_init( + wlightbox_s, hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test cover default state.""" _, entity_id = wlightbox_s @@ -239,7 +242,6 @@ async def test_wlightbox_s_init(wlightbox_s, hass: HomeAssistant) -> None: assert state.attributes[ATTR_BRIGHTNESS] is None assert state.state == STATE_UNKNOWN - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My wLightBoxS" @@ -326,7 +328,9 @@ def wlightbox_fixture(): return (feature, "light.wlightbox_color") -async def test_wlightbox_init(wlightbox, hass: HomeAssistant) -> None: +async def test_wlightbox_init( + wlightbox, hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test cover default state.""" _, entity_id = wlightbox @@ -343,7 +347,6 @@ async def test_wlightbox_init(wlightbox, hass: HomeAssistant) -> None: assert state.attributes[ATTR_RGBW_COLOR] is None assert state.state == STATE_UNKNOWN - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My wLightBox" diff --git a/tests/components/blebox/test_sensor.py b/tests/components/blebox/test_sensor.py index 1cfe36e70b6..68990a09a32 100644 --- a/tests/components/blebox/test_sensor.py +++ b/tests/components/blebox/test_sensor.py @@ -56,7 +56,9 @@ def tempsensor_fixture(): return (feature, "sensor.tempsensor_0_temperature") -async def test_init(tempsensor, hass: HomeAssistant) -> None: +async def test_init( + tempsensor, hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test sensor default state.""" _, entity_id = tempsensor @@ -70,7 +72,6 @@ async def test_init(tempsensor, hass: HomeAssistant) -> None: assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS assert state.state == STATE_UNKNOWN - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My temperature sensor" @@ -110,7 +111,9 @@ async def test_update_failure( assert f"Updating '{feature_mock.full_name}' failed: " in caplog.text -async def test_airsensor_init(airsensor, hass: HomeAssistant) -> None: +async def test_airsensor_init( + airsensor, hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test airSensor default state.""" _, entity_id = airsensor @@ -123,7 +126,6 @@ async def test_airsensor_init(airsensor, hass: HomeAssistant) -> None: assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.PM1 assert state.state == STATE_UNKNOWN - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My air sensor" diff --git a/tests/components/blebox/test_switch.py b/tests/components/blebox/test_switch.py index 5a425e799c3..db98a2705b2 100644 --- a/tests/components/blebox/test_switch.py +++ b/tests/components/blebox/test_switch.py @@ -44,7 +44,9 @@ def switchbox_fixture(): return (feature, "switch.switchbox_0_relay") -async def test_switchbox_init(switchbox, hass: HomeAssistant, config) -> None: +async def test_switchbox_init( + switchbox, hass: HomeAssistant, device_registry: dr.DeviceRegistry, config +) -> None: """Test switch default state.""" feature_mock, entity_id = switchbox @@ -60,7 +62,6 @@ async def test_switchbox_init(switchbox, hass: HomeAssistant, config) -> None: assert state.state == STATE_OFF - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My switch box" @@ -189,7 +190,9 @@ def switchbox_d_fixture(): return (features, ["switch.switchboxd_0_relay", "switch.switchboxd_1_relay"]) -async def test_switchbox_d_init(switchbox_d, hass: HomeAssistant) -> None: +async def test_switchbox_d_init( + switchbox_d, hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test switch default state.""" feature_mocks, entity_ids = switchbox_d @@ -206,7 +209,6 @@ async def test_switchbox_d_init(switchbox_d, hass: HomeAssistant) -> None: assert state.attributes[ATTR_DEVICE_CLASS] == SwitchDeviceClass.SWITCH assert state.state == STATE_UNKNOWN - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My relays" @@ -223,7 +225,6 @@ async def test_switchbox_d_init(switchbox_d, hass: HomeAssistant) -> None: assert state.attributes[ATTR_DEVICE_CLASS] == SwitchDeviceClass.SWITCH assert state.state == STATE_UNKNOWN - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My relays" diff --git a/tests/components/blink/conftest.py b/tests/components/blink/conftest.py new file mode 100644 index 00000000000..946840c23b9 --- /dev/null +++ b/tests/components/blink/conftest.py @@ -0,0 +1,97 @@ +"""Fixtures for the Blink integration tests.""" +from unittest.mock import AsyncMock, MagicMock, create_autospec, patch +from uuid import uuid4 + +import blinkpy +import pytest + +from homeassistant.components.blink.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +CAMERA_ATTRIBUTES = { + "name": "Camera 1", + "camera_id": "111111", + "serial": "serial", + "temperature": None, + "temperature_c": 25.1, + "temperature_calibrated": None, + "battery": "ok", + "battery_voltage": None, + "thumbnail": "https://rest-u034.immedia-semi.com/api/v3/media/accounts/111111/networks/222222/lotus/333333/thumbnail/thumbnail.jpg?ts=1698141602&ext=", + "video": None, + "recent_clips": [], + "motion_enabled": True, + "motion_detected": False, + "wifi_strength": None, + "network_id": 222222, + "sync_module": "sync module", + "last_record": None, + "type": "lotus", +} + + +@pytest.fixture +def camera() -> MagicMock: + """Set up a Blink camera fixture.""" + mock_blink_camera = create_autospec(blinkpy.camera.BlinkCamera, instance=True) + mock_blink_camera.sync = AsyncMock(return_value=True) + mock_blink_camera.name = "Camera 1" + mock_blink_camera.camera_id = "111111" + mock_blink_camera.serial = "12345" + mock_blink_camera.motion_enabled = True + mock_blink_camera.temperature = 25.1 + mock_blink_camera.motion_detected = False + mock_blink_camera.wifi_strength = 2.1 + mock_blink_camera.camera_type = "lotus" + mock_blink_camera.attributes = CAMERA_ATTRIBUTES + return mock_blink_camera + + +@pytest.fixture(name="mock_blink_api") +def blink_api_fixture(camera) -> MagicMock: + """Set up Blink API fixture.""" + mock_blink_api = create_autospec(blinkpy.blinkpy.Blink, instance=True) + mock_blink_api.available = True + mock_blink_api.start = AsyncMock(return_value=True) + mock_blink_api.refresh = AsyncMock(return_value=True) + mock_blink_api.sync = MagicMock(return_value=True) + mock_blink_api.cameras = {camera.name: camera} + + with patch("homeassistant.components.blink.Blink") as class_mock: + class_mock.return_value = mock_blink_api + yield mock_blink_api + + +@pytest.fixture(name="mock_blink_auth_api") +def blink_auth_api_fixture() -> MagicMock: + """Set up Blink API fixture.""" + mock_blink_auth_api = create_autospec(blinkpy.auth.Auth, instance=True) + mock_blink_auth_api.check_key_required.return_value = False + mock_blink_auth_api.send_auth_key = AsyncMock(return_value=True) + + with patch("homeassistant.components.blink.Auth", autospec=True) as class_mock: + class_mock.return_value = mock_blink_auth_api + yield mock_blink_auth_api + + +@pytest.fixture(name="mock_config_entry") +def mock_config_fixture(): + """Return a fake config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "test_user", + CONF_PASSWORD: "Password", + "device_id": "Home Assistant", + "uid": "BlinkCamera_e1233333e2-0909-09cd-777a-123456789012", + "token": "A_token", + "host": "u034.immedia-semi.com", + "region_id": "u034", + "client_id": 123456, + "account_id": 654321, + }, + entry_id=str(uuid4()), + version=3, + ) diff --git a/tests/components/blink/snapshots/test_diagnostics.ambr b/tests/components/blink/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..7fb13c97548 --- /dev/null +++ b/tests/components/blink/snapshots/test_diagnostics.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'cameras': dict({ + 'Camera 1': dict({ + 'battery': 'ok', + 'battery_voltage': None, + 'camera_id': '111111', + 'last_record': None, + 'motion_detected': False, + 'motion_enabled': True, + 'name': 'Camera 1', + 'network_id': 222222, + 'recent_clips': list([ + ]), + 'serial': '**REDACTED**', + 'sync_module': 'sync module', + 'temperature': None, + 'temperature_c': 25.1, + 'temperature_calibrated': None, + 'thumbnail': 'https://rest-u034.immedia-semi.com/api/v3/media/accounts/111111/networks/222222/lotus/333333/thumbnail/thumbnail.jpg?ts=1698141602&ext=', + 'type': 'lotus', + 'video': None, + 'wifi_strength': None, + }), + }), + 'config_entry': dict({ + 'data': dict({ + 'account_id': 654321, + 'client_id': 123456, + 'device_id': 'Home Assistant', + 'host': 'u034.immedia-semi.com', + 'password': '**REDACTED**', + 'region_id': 'u034', + 'token': '**REDACTED**', + 'uid': 'BlinkCamera_e1233333e2-0909-09cd-777a-123456789012', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'blink', + 'options': dict({ + 'scan_interval': 300, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': None, + 'version': 3, + }), + }) +# --- diff --git a/tests/components/blink/test_config_flow.py b/tests/components/blink/test_config_flow.py index ab04499c827..ada38451754 100644 --- a/tests/components/blink/test_config_flow.py +++ b/tests/components/blink/test_config_flow.py @@ -120,7 +120,8 @@ async def test_form_2fa_connect_error(hass: HomeAssistant) -> None: "homeassistant.components.blink.config_flow.Blink.setup_urls", side_effect=BlinkSetupError, ), patch( - "homeassistant.components.blink.async_setup_entry", return_value=True + "homeassistant.components.blink.async_setup_entry", + return_value=True, ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], {"pin": "1234"} @@ -161,7 +162,8 @@ async def test_form_2fa_invalid_key(hass: HomeAssistant) -> None: "homeassistant.components.blink.config_flow.Blink.setup_urls", return_value=True, ), patch( - "homeassistant.components.blink.async_setup_entry", return_value=True + "homeassistant.components.blink.async_setup_entry", + return_value=True, ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], {"pin": "1234"} @@ -200,7 +202,8 @@ async def test_form_2fa_unknown_error(hass: HomeAssistant) -> None: "homeassistant.components.blink.config_flow.Blink.setup_urls", side_effect=KeyError, ), patch( - "homeassistant.components.blink.async_setup_entry", return_value=True + "homeassistant.components.blink.async_setup_entry", + return_value=True, ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], {"pin": "1234"} diff --git a/tests/components/blink/test_diagnostics.py b/tests/components/blink/test_diagnostics.py new file mode 100644 index 00000000000..d447203dae6 --- /dev/null +++ b/tests/components/blink/test_diagnostics.py @@ -0,0 +1,33 @@ +"""Test Blink diagnostics.""" +from unittest.mock import MagicMock + +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.core import HomeAssistant + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + +YAML_CONFIG = {"username": "test-user", "password": "test-password"} + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + mock_blink_api: MagicMock, + mock_config_entry: MagicMock, +) -> None: + """Test config entry diagnostics.""" + + 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 get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result == snapshot(exclude=props("entry_id")) diff --git a/tests/components/blink/test_init.py b/tests/components/blink/test_init.py new file mode 100644 index 00000000000..f3d9beaf21a --- /dev/null +++ b/tests/components/blink/test_init.py @@ -0,0 +1,116 @@ +"""Test the Blink init.""" +import asyncio +from unittest.mock import AsyncMock, MagicMock + +from aiohttp import ClientError +import pytest + +from homeassistant.components.blink.const import ( + DOMAIN, + SERVICE_REFRESH, + SERVICE_SAVE_VIDEO, + SERVICE_SEND_PIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +CAMERA_NAME = "Camera 1" +FILENAME = "blah" +PIN = "1234" + + +@pytest.mark.parametrize( + ("the_error", "available"), + [(ClientError, False), (asyncio.TimeoutError, False), (None, False)], +) +async def test_setup_not_ready( + hass: HomeAssistant, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, + the_error, + available, +) -> None: + """Test setup failed because we can't connect to the Blink system.""" + + mock_blink_api.start = AsyncMock(side_effect=the_error) + mock_blink_api.available = available + + mock_config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_not_ready_authkey_required( + hass: HomeAssistant, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup failed because 2FA is needed to connect to the Blink system.""" + + mock_blink_auth_api.check_key_required = MagicMock(return_value=True) + + mock_config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_unload_entry_multiple( + hass: HomeAssistant, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test being able to unload one of 2 entries.""" + + 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() + hass.data[DOMAIN]["dummy"] = {1: 2} + assert mock_config_entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert hass.services.has_service(DOMAIN, SERVICE_REFRESH) + assert hass.services.has_service(DOMAIN, SERVICE_SAVE_VIDEO) + assert hass.services.has_service(DOMAIN, SERVICE_SEND_PIN) + + +async def test_migrate_V0( + hass: HomeAssistant, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test migration script version 0.""" + + mock_config_entry.version = 0 + + 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() + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize(("version"), [1, 2]) +async def test_migrate( + hass: HomeAssistant, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, + version, +) -> None: + """Test migration scripts.""" + + mock_config_entry.version = version + mock_config_entry.data = {**mock_config_entry.data, "login_response": "Blah"} + + mock_config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/blink/test_services.py b/tests/components/blink/test_services.py new file mode 100644 index 00000000000..ccc326dac1f --- /dev/null +++ b/tests/components/blink/test_services.py @@ -0,0 +1,377 @@ +"""Test the Blink services.""" +from unittest.mock import AsyncMock, MagicMock, Mock + +import pytest + +from homeassistant.components.blink.const import ( + DOMAIN, + SERVICE_REFRESH, + SERVICE_SAVE_RECENT_CLIPS, + SERVICE_SAVE_VIDEO, + SERVICE_SEND_PIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_DEVICE_ID, + CONF_FILE_PATH, + CONF_FILENAME, + CONF_NAME, + CONF_PIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + +CAMERA_NAME = "Camera 1" +FILENAME = "blah" +PIN = "1234" + + +async def test_refresh_service_calls( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test refrest service calls.""" + + 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, "12345")}) + + assert device_entry + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_blink_api.refresh.call_count == 1 + + await hass.services.async_call( + DOMAIN, + SERVICE_REFRESH, + {ATTR_DEVICE_ID: [device_entry.id]}, + blocking=True, + ) + + assert mock_blink_api.refresh.call_count == 2 + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_REFRESH, + {ATTR_DEVICE_ID: ["bad-device_id"]}, + blocking=True, + ) + + +async def test_video_service_calls( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test video service calls.""" + + 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, "12345")}) + + assert device_entry + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_blink_api.refresh.call_count == 1 + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_VIDEO, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILENAME: FILENAME, + }, + blocking=True, + ) + + hass.config.is_allowed_path = Mock(return_value=True) + caplog.clear() + mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_VIDEO, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILENAME: FILENAME, + }, + blocking=True, + ) + mock_blink_api.cameras[CAMERA_NAME].video_to_file.assert_awaited_once() + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_VIDEO, + { + ATTR_DEVICE_ID: ["bad-device_id"], + CONF_NAME: CAMERA_NAME, + CONF_FILENAME: FILENAME, + }, + blocking=True, + ) + + mock_blink_api.cameras[CAMERA_NAME].video_to_file = AsyncMock(side_effect=OSError) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_VIDEO, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILENAME: FILENAME, + }, + blocking=True, + ) + + hass.config.is_allowed_path = Mock(return_value=False) + + +async def test_picture_service_calls( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test picture servcie calls.""" + + 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, "12345")}) + + assert device_entry + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_blink_api.refresh.call_count == 1 + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILE_PATH: FILENAME, + }, + blocking=True, + ) + + hass.config.is_allowed_path = Mock(return_value=True) + mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} + + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILE_PATH: FILENAME, + }, + blocking=True, + ) + mock_blink_api.cameras[CAMERA_NAME].save_recent_clips.assert_awaited_once() + + mock_blink_api.cameras[CAMERA_NAME].save_recent_clips = AsyncMock( + side_effect=OSError + ) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILE_PATH: FILENAME, + }, + blocking=True, + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + { + ATTR_DEVICE_ID: ["bad-device_id"], + CONF_NAME: CAMERA_NAME, + CONF_FILE_PATH: FILENAME, + }, + blocking=True, + ) + + +async def test_pin_service_calls( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test pin service calls.""" + + 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, "12345")}) + + assert device_entry + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_blink_api.refresh.call_count == 1 + + await hass.services.async_call( + DOMAIN, + SERVICE_SEND_PIN, + {ATTR_DEVICE_ID: [device_entry.id], CONF_PIN: PIN}, + blocking=True, + ) + assert mock_blink_api.auth.send_auth_key.assert_awaited_once + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SEND_PIN, + {ATTR_DEVICE_ID: ["bad-device_id"], CONF_PIN: PIN}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("service", "params"), + [ + (SERVICE_SEND_PIN, {CONF_PIN: PIN}), + ( + SERVICE_SAVE_RECENT_CLIPS, + { + CONF_NAME: CAMERA_NAME, + CONF_FILE_PATH: FILENAME, + }, + ), + ( + SERVICE_SAVE_VIDEO, + { + CONF_NAME: CAMERA_NAME, + CONF_FILENAME: FILENAME, + }, + ), + ], +) +async def test_service_called_with_non_blink_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, + service, + params, +) -> None: + """Test service calls with non blink device.""" + + 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() + + other_domain = "NotBlink" + other_config_id = "555" + await hass.config_entries.async_add( + MockConfigEntry( + title="Not Blink", domain=other_domain, entry_id=other_config_id + ) + ) + device_entry = device_registry.async_get_or_create( + config_entry_id=other_config_id, + identifiers={ + (other_domain, 1), + }, + ) + + hass.config.is_allowed_path = Mock(return_value=True) + mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} + + parameters = {ATTR_DEVICE_ID: [device_entry.id]} + parameters.update(params) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + service, + parameters, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("service", "params"), + [ + (SERVICE_SEND_PIN, {CONF_PIN: PIN}), + ( + SERVICE_SAVE_RECENT_CLIPS, + { + CONF_NAME: CAMERA_NAME, + CONF_FILE_PATH: FILENAME, + }, + ), + ( + SERVICE_SAVE_VIDEO, + { + CONF_NAME: CAMERA_NAME, + CONF_FILENAME: FILENAME, + }, + ), + ], +) +async def test_service_called_with_unloaded_entry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, + service, + params, +) -> None: + """Test service calls with unloaded config entry.""" + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + await mock_config_entry.async_unload(hass) + + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "12345")}) + + assert device_entry + + hass.config.is_allowed_path = Mock(return_value=True) + mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} + + parameters = {ATTR_DEVICE_ID: [device_entry.id]} + parameters.update(params) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + service, + parameters, + blocking=True, + ) diff --git a/tests/components/blueprint/test_models.py b/tests/components/blueprint/test_models.py index b2d3ce517d8..c11a467de9b 100644 --- a/tests/components/blueprint/test_models.py +++ b/tests/components/blueprint/test_models.py @@ -1,6 +1,6 @@ """Test blueprint models.""" import logging -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest @@ -49,7 +49,7 @@ def blueprint_2(): def domain_bps(hass): """Domain blueprints fixture.""" return models.DomainBlueprints( - hass, "automation", logging.getLogger(__name__), None + hass, "automation", logging.getLogger(__name__), None, AsyncMock() ) @@ -257,13 +257,9 @@ async def test_domain_blueprints_inputs_from_config(domain_bps, blueprint_1) -> async def test_domain_blueprints_add_blueprint(domain_bps, blueprint_1) -> None: """Test DomainBlueprints.async_add_blueprint.""" with patch.object(domain_bps, "_create_file") as create_file_mock: - # Should add extension when not present. - await domain_bps.async_add_blueprint(blueprint_1, "something") + await domain_bps.async_add_blueprint(blueprint_1, "something.yaml") assert create_file_mock.call_args[0][1] == "something.yaml" - await domain_bps.async_add_blueprint(blueprint_1, "something2.yaml") - assert create_file_mock.call_args[0][1] == "something2.yaml" - # Should be in cache. with patch.object(domain_bps, "_load_blueprint") as mock_load: assert await domain_bps.async_get_blueprint("something.yaml") == blueprint_1 diff --git a/tests/components/blueprint/test_websocket_api.py b/tests/components/blueprint/test_websocket_api.py index f831445b60c..b0439896c25 100644 --- a/tests/components/blueprint/test_websocket_api.py +++ b/tests/components/blueprint/test_websocket_api.py @@ -3,6 +3,7 @@ from pathlib import Path from unittest.mock import Mock, patch import pytest +import yaml from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -129,6 +130,52 @@ async def test_import_blueprint( }, }, "validation_errors": None, + "exists": False, + } + + +async def test_import_blueprint_update( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, + setup_bp, +) -> None: + """Test importing blueprints.""" + raw_data = Path( + hass.config.path("blueprints/automation/in_folder/in_folder_blueprint.yaml") + ).read_text() + + aioclient_mock.get( + "https://raw.githubusercontent.com/in_folder/home-assistant-config/main/blueprints/automation/in_folder_blueprint.yaml", + text=raw_data, + ) + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 5, + "type": "blueprint/import", + "url": "https://github.com/in_folder/home-assistant-config/blob/main/blueprints/automation/in_folder_blueprint.yaml", + } + ) + + msg = await client.receive_json() + + assert msg["id"] == 5 + assert msg["success"] + assert msg["result"] == { + "suggested_filename": "in_folder/in_folder_blueprint", + "raw_data": raw_data, + "blueprint": { + "metadata": { + "domain": "automation", + "input": {"action": None, "trigger": None}, + "name": "In Folder Blueprint", + "source_url": "https://github.com/in_folder/home-assistant-config/blob/main/blueprints/automation/in_folder_blueprint.yaml", + } + }, + "validation_errors": None, + "exists": True, } @@ -212,6 +259,42 @@ async def test_save_existing_file( assert msg["error"] == {"code": "already_exists", "message": "File already exists"} +async def test_save_existing_file_override( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test saving blueprints.""" + + client = await hass_ws_client(hass) + with patch("pathlib.Path.write_text") as write_mock: + await client.send_json( + { + "id": 7, + "type": "blueprint/save", + "path": "test_event_service", + "yaml": 'blueprint: {name: "name", domain: "automation"}', + "domain": "automation", + "source_url": "https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/test_event_service.yaml", + "allow_override": True, + } + ) + + msg = await client.receive_json() + + assert msg["id"] == 7 + assert msg["success"] + assert msg["result"] == {"overrides_existing": True} + assert yaml.safe_load(write_mock.mock_calls[0][1][0]) == { + "blueprint": { + "name": "name", + "domain": "automation", + "source_url": "https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/test_event_service.yaml", + "input": {}, + } + } + + async def test_save_file_error( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -355,7 +438,7 @@ async def test_delete_blueprint_in_use_by_automation( assert msg["id"] == 9 assert not msg["success"] assert msg["error"] == { - "code": "unknown_error", + "code": "home_assistant_error", "message": "Blueprint in use", } @@ -401,6 +484,6 @@ async def test_delete_blueprint_in_use_by_script( assert msg["id"] == 9 assert not msg["success"] assert msg["error"] == { - "code": "unknown_error", + "code": "home_assistant_error", "message": "Blueprint in use", } diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 59c5cc822df..5f166a3fca2 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -47,12 +47,14 @@ def mock_operating_system_90(): def macos_adapter(): """Fixture that mocks the macos adapter.""" with patch("bleak.get_platform_scanner_backend_type"), patch( - "homeassistant.components.bluetooth.platform.system", return_value="Darwin" + "homeassistant.components.bluetooth.platform.system", + return_value="Darwin", ), patch( "homeassistant.components.bluetooth.scanner.platform.system", return_value="Darwin", ), patch( - "bluetooth_adapters.systems.platform.system", return_value="Darwin" + "bluetooth_adapters.systems.platform.system", + return_value="Darwin", ): yield @@ -71,14 +73,16 @@ def windows_adapter(): def no_adapter_fixture(): """Fixture that mocks no adapters on Linux.""" with patch( - "homeassistant.components.bluetooth.platform.system", return_value="Linux" + "homeassistant.components.bluetooth.platform.system", + return_value="Linux", ), patch( "homeassistant.components.bluetooth.scanner.platform.system", return_value="Linux", ), patch( - "bluetooth_adapters.systems.platform.system", return_value="Linux" + "bluetooth_adapters.systems.platform.system", + return_value="Linux", ), patch( - "bluetooth_adapters.systems.linux.LinuxAdapters.refresh" + "bluetooth_adapters.systems.linux.LinuxAdapters.refresh", ), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.adapters", {}, @@ -90,14 +94,16 @@ def no_adapter_fixture(): def one_adapter_fixture(): """Fixture that mocks one adapter on Linux.""" with patch( - "homeassistant.components.bluetooth.platform.system", return_value="Linux" + "homeassistant.components.bluetooth.platform.system", + return_value="Linux", ), patch( "homeassistant.components.bluetooth.scanner.platform.system", return_value="Linux", ), patch( - "bluetooth_adapters.systems.platform.system", return_value="Linux" + "bluetooth_adapters.systems.platform.system", + return_value="Linux", ), patch( - "bluetooth_adapters.systems.linux.LinuxAdapters.refresh" + "bluetooth_adapters.systems.linux.LinuxAdapters.refresh", ), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.adapters", { @@ -124,9 +130,7 @@ def two_adapters_fixture(): ), patch( "homeassistant.components.bluetooth.scanner.platform.system", return_value="Linux", - ), patch( - "bluetooth_adapters.systems.platform.system", return_value="Linux" - ), patch( + ), patch("bluetooth_adapters.systems.platform.system", return_value="Linux"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.refresh" ), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.adapters", @@ -166,9 +170,7 @@ def one_adapter_old_bluez(): ), patch( "homeassistant.components.bluetooth.scanner.platform.system", return_value="Linux", - ), patch( - "bluetooth_adapters.systems.platform.system", return_value="Linux" - ), patch( + ), patch("bluetooth_adapters.systems.platform.system", return_value="Linux"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.refresh" ), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.adapters", diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr index 32405d93e6b..b3af5bc59b6 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -413,7 +413,6 @@ 'servicePack': 'WAVE_01', }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'is_metric': True, 'mappingInfo': dict({ 'isAssociated': False, 'isLmmEnabled': False, @@ -1288,7 +1287,6 @@ 'servicePack': 'WAVE_01', }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'is_metric': True, 'mappingInfo': dict({ 'isAssociated': False, 'isLmmEnabled': False, @@ -1979,7 +1977,6 @@ 'charging_settings': dict({ }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'is_metric': True, 'mappingInfo': dict({ 'isAssociated': False, 'isLmmEnabled': False, @@ -2734,7 +2731,6 @@ 'servicePack': 'TCB1', }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'is_metric': True, 'mappingInfo': dict({ 'isPrimaryUser': True, 'mappingStatus': 'CONFIRMED', @@ -5070,7 +5066,6 @@ 'servicePack': 'TCB1', }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'is_metric': True, 'mappingInfo': dict({ 'isPrimaryUser': True, 'mappingStatus': 'CONFIRMED', diff --git a/tests/components/bmw_connected_drive/test_diagnostics.py b/tests/components/bmw_connected_drive/test_diagnostics.py index 0509409ad0a..11c2b055f6d 100644 --- a/tests/components/bmw_connected_drive/test_diagnostics.py +++ b/tests/components/bmw_connected_drive/test_diagnostics.py @@ -45,6 +45,7 @@ async def test_config_entry_diagnostics( async def test_device_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, bmw_fixture, snapshot: SnapshotAssertion, ) -> None: @@ -56,7 +57,6 @@ async def test_device_diagnostics( mock_config_entry = await setup_mocked_integration(hass) - device_registry = dr.async_get(hass) reg_device = device_registry.async_get_device( identifiers={(DOMAIN, "WBY00000000REXI01")}, ) @@ -73,6 +73,7 @@ async def test_device_diagnostics( async def test_device_diagnostics_vehicle_not_found( hass: HomeAssistant, hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, bmw_fixture, snapshot: SnapshotAssertion, ) -> None: @@ -84,7 +85,6 @@ async def test_device_diagnostics_vehicle_not_found( mock_config_entry = await setup_mocked_integration(hass) - device_registry = dr.async_get(hass) reg_device = device_registry.async_get_device( identifiers={(DOMAIN, "WBY00000000REXI01")}, ) diff --git a/tests/components/bmw_connected_drive/test_init.py b/tests/components/bmw_connected_drive/test_init.py index aab41bf6339..bc02437f5ba 100644 --- a/tests/components/bmw_connected_drive/test_init.py +++ b/tests/components/bmw_connected_drive/test_init.py @@ -49,12 +49,12 @@ async def test_migrate_unique_ids( entitydata: dict, old_unique_id: str, new_unique_id: str, + entity_registry: er.EntityRegistry, ) -> None: """Test successful migration of entity unique_ids.""" mock_config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) mock_config_entry.add_to_hass(hass) - entity_registry = er.async_get(hass) entity: er.RegistryEntry = entity_registry.async_get_or_create( **entitydata, config_entry=mock_config_entry, @@ -95,13 +95,12 @@ async def test_dont_migrate_unique_ids( entitydata: dict, old_unique_id: str, new_unique_id: str, + entity_registry: er.EntityRegistry, ) -> None: """Test successful migration of entity unique_ids.""" mock_config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) mock_config_entry.add_to_hass(hass) - entity_registry = er.async_get(hass) - # create existing entry with new_unique_id existing_entity = entity_registry.async_get_or_create( SENSOR_DOMAIN, diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 6fbcb928b5a..ff1f986583e 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -67,13 +67,9 @@ async def setup_bond_entity( enabled=patch_token ), patch_bond_version(enabled=patch_version), patch_bond_device_ids( enabled=patch_device_ids - ), patch_setup_entry( - "cover", enabled=patch_platforms - ), patch_setup_entry( + ), patch_setup_entry("cover", enabled=patch_platforms), patch_setup_entry( "fan", enabled=patch_platforms - ), patch_setup_entry( - "light", enabled=patch_platforms - ), patch_setup_entry( + ), patch_setup_entry("light", enabled=patch_platforms), patch_setup_entry( "switch", enabled=patch_platforms ): return await hass.config_entries.async_setup(config_entry.entry_id) @@ -102,15 +98,11 @@ async def setup_platform( "homeassistant.components.bond.PLATFORMS", [platform] ), patch_bond_version(return_value=bond_version), patch_bond_bridge( return_value=bridge - ), patch_bond_token( - return_value=token - ), patch_bond_device_ids( + ), patch_bond_token(return_value=token), patch_bond_device_ids( return_value=[bond_device_id] ), patch_start_bpup(), patch_bond_device( return_value=discovered_device - ), patch_bond_device_properties( - return_value=props - ), patch_bond_device_state( + ), patch_bond_device_properties(return_value=props), patch_bond_device_state( return_value=state ): assert await async_setup_component(hass, BOND_DOMAIN, {}) diff --git a/tests/components/bond/test_button.py b/tests/components/bond/test_button.py index 9878050e6cf..6984831626d 100644 --- a/tests/components/bond/test_button.py +++ b/tests/components/bond/test_button.py @@ -6,7 +6,6 @@ from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRE from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry from .common import patch_bond_action, patch_bond_device_state, setup_platform @@ -57,7 +56,10 @@ def light(name: str): } -async def test_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_registry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform( hass, @@ -67,14 +69,13 @@ async def test_entity_registry(hass: HomeAssistant) -> None: bond_device_id="test-device-id", ) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities["button.name_1_stop_actions"] + entity = entity_registry.entities["button.name_1_stop_actions"] assert entity.unique_id == "test-hub-id_test-device-id_stop" - entity = registry.entities["button.name_1_start_increasing_brightness"] + entity = entity_registry.entities["button.name_1_start_increasing_brightness"] assert entity.unique_id == "test-hub-id_test-device-id_startincreasingbrightness" - entity = registry.entities["button.name_1_start_decreasing_brightness"] + entity = entity_registry.entities["button.name_1_start_decreasing_brightness"] assert entity.unique_id == "test-hub-id_test-device-id_startdecreasingbrightness" - entity = registry.entities["button.name_1_start_dimmer"] + entity = entity_registry.entities["button.name_1_start_dimmer"] assert entity.unique_id == "test-hub-id_test-device-id_startdimmer" diff --git a/tests/components/bond/test_cover.py b/tests/components/bond/test_cover.py index 8f3e9c09922..e489f8550d6 100644 --- a/tests/components/bond/test_cover.py +++ b/tests/components/bond/test_cover.py @@ -23,7 +23,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow from .common import ( @@ -72,7 +71,10 @@ def tilt_shades(name: str): } -async def test_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_registry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform( hass, @@ -82,8 +84,7 @@ async def test_entity_registry(hass: HomeAssistant) -> None: bond_device_id="test-device-id", ) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities["cover.name_1"] + entity = entity_registry.entities["cover.name_1"] assert entity.unique_id == "test-hub-id_test-device-id" diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index f2fa109af22..e202433c8d6 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -26,6 +26,7 @@ from homeassistant.components.fan import ( SERVICE_SET_PERCENTAGE, SERVICE_SET_PRESET_MODE, FanEntityFeature, + NotValidPresetModeError, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -36,7 +37,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow from .common import ( @@ -81,7 +81,11 @@ async def turn_fan_on( await hass.async_block_till_done() -async def test_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_registry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform( hass, @@ -91,11 +95,9 @@ async def test_entity_registry(hass: HomeAssistant) -> None: bond_device_id="test-device-id", ) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities["fan.name_1"] + entity = entity_registry.entities["fan.name_1"] assert entity.unique_id == "test-hub-id_test-device-id" - device_registry = dr.async_get(hass) device = device_registry.async_get(entity.device_id) assert device.configuration_url == "http://some host" @@ -250,10 +252,14 @@ async def test_turn_on_fan_preset_mode_not_supported(hass: HomeAssistant) -> Non props={"max_speed": 6}, ) - with patch_bond_action(), patch_bond_device_state(), pytest.raises(ValueError): + with patch_bond_action(), patch_bond_device_state(), pytest.raises( + NotValidPresetModeError + ): await turn_fan_on(hass, "fan.name_1", preset_mode=PRESET_MODE_BREEZE) - with patch_bond_action(), patch_bond_device_state(), pytest.raises(ValueError): + with patch_bond_action(), patch_bond_device_state(), pytest.raises( + NotValidPresetModeError + ): await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PRESET_MODE, @@ -476,7 +482,11 @@ async def test_fan_available(hass: HomeAssistant) -> None: ) -async def test_setup_smart_by_bond_fan(hass: HomeAssistant) -> None: +async def test_setup_smart_by_bond_fan( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: """Test setting up a fan without a hub.""" config_entry = await setup_platform( hass, @@ -491,10 +501,8 @@ async def test_setup_smart_by_bond_fan(hass: HomeAssistant) -> None: }, ) assert hass.states.get("fan.name_1") is not None - registry = er.async_get(hass) - entry = registry.async_get("fan.name_1") + entry = entity_registry.async_get("fan.name_1") assert entry.device_id is not None - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device is not None assert device.sw_version == "test-version" @@ -505,7 +513,11 @@ async def test_setup_smart_by_bond_fan(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_setup_hub_template_fan(hass: HomeAssistant) -> None: +async def test_setup_hub_template_fan( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: """Test setting up a fan on a hub created from a template.""" config_entry = await setup_platform( hass, @@ -521,10 +533,8 @@ async def test_setup_hub_template_fan(hass: HomeAssistant) -> None: }, ) assert hass.states.get("fan.name_1") is not None - registry = er.async_get(hass) - entry = registry.async_get("fan.name_1") + entry = entity_registry.async_get("fan.name_1") assert entry.device_id is not None - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device is not None assert device.sw_version is None diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 33919219301..6b462a02c26 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -12,7 +12,6 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ASSUMED_STATE, CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from .common import ( @@ -82,6 +81,7 @@ async def test_async_setup_raises_fails_if_auth_fails(hass: HomeAssistant) -> No async def test_async_setup_entry_sets_up_hub_and_supported_domains( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, ) -> None: """Test that configuring entry sets up cover domain.""" config_entry = MockConfigEntry( @@ -112,7 +112,6 @@ async def test_async_setup_entry_sets_up_hub_and_supported_domains( assert config_entry.unique_id == "ZXXX12345" # verify hub device is registered correctly - device_registry = dr.async_get(hass) hub = device_registry.async_get_device(identifiers={(DOMAIN, "ZXXX12345")}) assert hub.name == "bond-name" assert hub.manufacturer == "Olibra" @@ -153,7 +152,9 @@ async def test_unload_config_entry(hass: HomeAssistant) -> None: assert config_entry.state is ConfigEntryState.NOT_LOADED -async def test_old_identifiers_are_removed(hass: HomeAssistant) -> None: +async def test_old_identifiers_are_removed( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test we remove the old non-unique identifiers.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -163,7 +164,6 @@ async def test_old_identifiers_are_removed(hass: HomeAssistant) -> None: old_identifers = (DOMAIN, "device_id") new_identifiers = (DOMAIN, "ZXXX12345", "device_id") - device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={old_identifers}, @@ -184,9 +184,7 @@ async def test_old_identifiers_are_removed(hass: HomeAssistant) -> None: "name": "test1", "type": DeviceType.GENERIC_DEVICE, } - ), patch_bond_device_properties( - return_value={} - ), patch_bond_device_state( + ), patch_bond_device_properties(return_value={}), patch_bond_device_state( return_value={} ): assert await hass.config_entries.async_setup(config_entry.entry_id) is True @@ -201,7 +199,9 @@ async def test_old_identifiers_are_removed(hass: HomeAssistant) -> None: assert device_registry.async_get_device(identifiers={new_identifiers}) is not None -async def test_smart_by_bond_device_suggested_area(hass: HomeAssistant) -> None: +async def test_smart_by_bond_device_suggested_area( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test we can setup a smart by bond device and get the suggested area.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -226,9 +226,7 @@ async def test_smart_by_bond_device_suggested_area(hass: HomeAssistant) -> None: "type": DeviceType.GENERIC_DEVICE, "location": "Den", } - ), patch_bond_device_properties( - return_value={} - ), patch_bond_device_state( + ), patch_bond_device_properties(return_value={}), patch_bond_device_state( return_value={} ): assert await hass.config_entries.async_setup(config_entry.entry_id) is True @@ -238,13 +236,14 @@ async def test_smart_by_bond_device_suggested_area(hass: HomeAssistant) -> None: assert config_entry.state is ConfigEntryState.LOADED assert config_entry.unique_id == "KXXX12345" - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, "KXXX12345")}) assert device is not None assert device.suggested_area == "Den" -async def test_bridge_device_suggested_area(hass: HomeAssistant) -> None: +async def test_bridge_device_suggested_area( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test we can setup a bridge bond device and get the suggested area.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -272,9 +271,7 @@ async def test_bridge_device_suggested_area(hass: HomeAssistant) -> None: "type": DeviceType.GENERIC_DEVICE, "location": "Bathroom", } - ), patch_bond_device_properties( - return_value={} - ), patch_bond_device_state( + ), patch_bond_device_properties(return_value={}), patch_bond_device_state( return_value={} ): assert await hass.config_entries.async_setup(config_entry.entry_id) is True @@ -284,14 +281,16 @@ async def test_bridge_device_suggested_area(hass: HomeAssistant) -> None: assert config_entry.state is ConfigEntryState.LOADED assert config_entry.unique_id == "ZXXX12345" - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, "ZXXX12345")}) assert device is not None assert device.suggested_area == "Office" async def test_device_remove_devices( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, ) -> None: """Test we can only remove a device that no longer exists.""" assert await async_setup_component(hass, "config", {}) @@ -304,11 +303,9 @@ async def test_device_remove_devices( bond_device_id="test-device-id", ) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities["fan.name_1"] + entity = entity_registry.entities["fan.name_1"] assert entity.unique_id == "test-hub-id_test-device-id" - device_registry = dr.async_get(hass) device_entry = device_registry.async_get(entity.device_id) assert ( await remove_device( diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index 6cbd43b221b..10395f395dd 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -32,7 +32,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow from .common import ( @@ -153,7 +152,10 @@ def light_brightness_increase_decrease_only(name: str): } -async def test_fan_entity_registry(hass: HomeAssistant) -> None: +async def test_fan_entity_registry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Tests that fan with light devices are registered in the entity registry.""" await setup_platform( hass, @@ -163,12 +165,14 @@ async def test_fan_entity_registry(hass: HomeAssistant) -> None: bond_device_id="test-device-id", ) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities["light.fan_name"] + entity = entity_registry.entities["light.fan_name"] assert entity.unique_id == "test-hub-id_test-device-id" -async def test_fan_up_light_entity_registry(hass: HomeAssistant) -> None: +async def test_fan_up_light_entity_registry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Tests that fan with up light devices are registered in the entity registry.""" await setup_platform( hass, @@ -178,12 +182,14 @@ async def test_fan_up_light_entity_registry(hass: HomeAssistant) -> None: bond_device_id="test-device-id", ) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities["light.fan_name_up_light"] + entity = entity_registry.entities["light.fan_name_up_light"] assert entity.unique_id == "test-hub-id_test-device-id_up_light" -async def test_fan_down_light_entity_registry(hass: HomeAssistant) -> None: +async def test_fan_down_light_entity_registry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Tests that fan with down light devices are registered in the entity registry.""" await setup_platform( hass, @@ -193,12 +199,14 @@ async def test_fan_down_light_entity_registry(hass: HomeAssistant) -> None: bond_device_id="test-device-id", ) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities["light.fan_name_down_light"] + entity = entity_registry.entities["light.fan_name_down_light"] assert entity.unique_id == "test-hub-id_test-device-id_down_light" -async def test_fireplace_entity_registry(hass: HomeAssistant) -> None: +async def test_fireplace_entity_registry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Tests that flame fireplace devices are registered in the entity registry.""" await setup_platform( hass, @@ -208,12 +216,14 @@ async def test_fireplace_entity_registry(hass: HomeAssistant) -> None: bond_device_id="test-device-id", ) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities["light.fireplace_name"] + entity = entity_registry.entities["light.fireplace_name"] assert entity.unique_id == "test-hub-id_test-device-id" -async def test_fireplace_with_light_entity_registry(hass: HomeAssistant) -> None: +async def test_fireplace_with_light_entity_registry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Tests that flame+light devices are registered in the entity registry.""" await setup_platform( hass, @@ -223,14 +233,16 @@ async def test_fireplace_with_light_entity_registry(hass: HomeAssistant) -> None bond_device_id="test-device-id", ) - registry: EntityRegistry = er.async_get(hass) - entity_flame = registry.entities["light.fireplace_name"] + entity_flame = entity_registry.entities["light.fireplace_name"] assert entity_flame.unique_id == "test-hub-id_test-device-id" - entity_light = registry.entities["light.fireplace_name_light"] + entity_light = entity_registry.entities["light.fireplace_name_light"] assert entity_light.unique_id == "test-hub-id_test-device-id_light" -async def test_light_entity_registry(hass: HomeAssistant) -> None: +async def test_light_entity_registry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Tests lights are registered in the entity registry.""" await setup_platform( hass, @@ -240,8 +252,7 @@ async def test_light_entity_registry(hass: HomeAssistant) -> None: bond_device_id="test-device-id", ) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities["light.light_name"] + entity = entity_registry.entities["light.light_name"] assert entity.unique_id == "test-hub-id_test-device-id" diff --git a/tests/components/bond/test_switch.py b/tests/components/bond/test_switch.py index 06d2e0b4c64..1ab9ef2165c 100644 --- a/tests/components/bond/test_switch.py +++ b/tests/components/bond/test_switch.py @@ -14,7 +14,6 @@ from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_O from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow from .common import ( @@ -33,7 +32,10 @@ def generic_device(name: str): return {"name": name, "type": DeviceType.GENERIC_DEVICE} -async def test_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_registry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform( hass, @@ -43,8 +45,7 @@ async def test_entity_registry(hass: HomeAssistant) -> None: bond_device_id="test-device-id", ) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities["switch.name_1"] + entity = entity_registry.entities["switch.name_1"] assert entity.unique_id == "test-hub-id_test-device-id" diff --git a/tests/components/brother/test_sensor.py b/tests/components/brother/test_sensor.py index 42bcb9847f1..4bb5732e616 100644 --- a/tests/components/brother/test_sensor.py +++ b/tests/components/brother/test_sensor.py @@ -32,14 +32,12 @@ ATTR_REMAINING_PAGES = "remaining_pages" ATTR_COUNTER = "counter" -async def test_sensors(hass: HomeAssistant) -> None: +async def test_sensors(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test states of the sensors.""" entry = await init_integration(hass, skip_setup=True) - registry = er.async_get(hass) - # Pre-create registry entries for disabled by default sensors - registry.async_get_or_create( + entity_registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, "0123456789_uptime", @@ -62,7 +60,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "waiting" assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.hl_l2340dw_status") + entry = entity_registry.async_get("sensor.hl_l2340dw_status") assert entry assert entry.unique_id == "0123456789_status" @@ -73,7 +71,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "75" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_black_toner_remaining") + entry = entity_registry.async_get("sensor.hl_l2340dw_black_toner_remaining") assert entry assert entry.unique_id == "0123456789_black_toner_remaining" @@ -84,7 +82,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "10" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_cyan_toner_remaining") + entry = entity_registry.async_get("sensor.hl_l2340dw_cyan_toner_remaining") assert entry assert entry.unique_id == "0123456789_cyan_toner_remaining" @@ -95,7 +93,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "8" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_magenta_toner_remaining") + entry = entity_registry.async_get("sensor.hl_l2340dw_magenta_toner_remaining") assert entry assert entry.unique_id == "0123456789_magenta_toner_remaining" @@ -106,7 +104,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "2" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_yellow_toner_remaining") + entry = entity_registry.async_get("sensor.hl_l2340dw_yellow_toner_remaining") assert entry assert entry.unique_id == "0123456789_yellow_toner_remaining" @@ -117,7 +115,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_drum_remaining_lifetime") + entry = entity_registry.async_get("sensor.hl_l2340dw_drum_remaining_lifetime") assert entry assert entry.unique_id == "0123456789_drum_remaining_life" @@ -128,7 +126,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "11014" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_drum_remaining_pages") + entry = entity_registry.async_get("sensor.hl_l2340dw_drum_remaining_pages") assert entry assert entry.unique_id == "0123456789_drum_remaining_pages" @@ -139,7 +137,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "986" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_drum_page_counter") + entry = entity_registry.async_get("sensor.hl_l2340dw_drum_page_counter") assert entry assert entry.unique_id == "0123456789_drum_counter" @@ -150,7 +148,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_black_drum_remaining_lifetime") + entry = entity_registry.async_get("sensor.hl_l2340dw_black_drum_remaining_lifetime") assert entry assert entry.unique_id == "0123456789_black_drum_remaining_life" @@ -161,7 +159,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "16389" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_black_drum_remaining_pages") + entry = entity_registry.async_get("sensor.hl_l2340dw_black_drum_remaining_pages") assert entry assert entry.unique_id == "0123456789_black_drum_remaining_pages" @@ -172,7 +170,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "1611" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_black_drum_page_counter") + entry = entity_registry.async_get("sensor.hl_l2340dw_black_drum_page_counter") assert entry assert entry.unique_id == "0123456789_black_drum_counter" @@ -183,7 +181,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_cyan_drum_remaining_lifetime") + entry = entity_registry.async_get("sensor.hl_l2340dw_cyan_drum_remaining_lifetime") assert entry assert entry.unique_id == "0123456789_cyan_drum_remaining_life" @@ -194,7 +192,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "16389" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_cyan_drum_remaining_pages") + entry = entity_registry.async_get("sensor.hl_l2340dw_cyan_drum_remaining_pages") assert entry assert entry.unique_id == "0123456789_cyan_drum_remaining_pages" @@ -205,7 +203,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "1611" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_cyan_drum_page_counter") + entry = entity_registry.async_get("sensor.hl_l2340dw_cyan_drum_page_counter") assert entry assert entry.unique_id == "0123456789_cyan_drum_counter" @@ -216,7 +214,9 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_magenta_drum_remaining_lifetime") + entry = entity_registry.async_get( + "sensor.hl_l2340dw_magenta_drum_remaining_lifetime" + ) assert entry assert entry.unique_id == "0123456789_magenta_drum_remaining_life" @@ -227,7 +227,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "16389" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_magenta_drum_remaining_pages") + entry = entity_registry.async_get("sensor.hl_l2340dw_magenta_drum_remaining_pages") assert entry assert entry.unique_id == "0123456789_magenta_drum_remaining_pages" @@ -238,7 +238,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "1611" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_magenta_drum_page_counter") + entry = entity_registry.async_get("sensor.hl_l2340dw_magenta_drum_page_counter") assert entry assert entry.unique_id == "0123456789_magenta_drum_counter" @@ -249,7 +249,9 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_yellow_drum_remaining_lifetime") + entry = entity_registry.async_get( + "sensor.hl_l2340dw_yellow_drum_remaining_lifetime" + ) assert entry assert entry.unique_id == "0123456789_yellow_drum_remaining_life" @@ -260,7 +262,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "16389" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_yellow_drum_remaining_pages") + entry = entity_registry.async_get("sensor.hl_l2340dw_yellow_drum_remaining_pages") assert entry assert entry.unique_id == "0123456789_yellow_drum_remaining_pages" @@ -271,7 +273,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "1611" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_yellow_drum_page_counter") + entry = entity_registry.async_get("sensor.hl_l2340dw_yellow_drum_page_counter") assert entry assert entry.unique_id == "0123456789_yellow_drum_counter" @@ -282,7 +284,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "97" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_fuser_remaining_lifetime") + entry = entity_registry.async_get("sensor.hl_l2340dw_fuser_remaining_lifetime") assert entry assert entry.unique_id == "0123456789_fuser_remaining_life" @@ -293,7 +295,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "97" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_belt_unit_remaining_lifetime") + entry = entity_registry.async_get("sensor.hl_l2340dw_belt_unit_remaining_lifetime") assert entry assert entry.unique_id == "0123456789_belt_unit_remaining_life" @@ -304,7 +306,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "98" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_pf_kit_1_remaining_lifetime") + entry = entity_registry.async_get("sensor.hl_l2340dw_pf_kit_1_remaining_lifetime") assert entry assert entry.unique_id == "0123456789_pf_kit_1_remaining_life" @@ -315,7 +317,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "986" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_page_counter") + entry = entity_registry.async_get("sensor.hl_l2340dw_page_counter") assert entry assert entry.unique_id == "0123456789_page_counter" @@ -326,7 +328,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "538" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_duplex_unit_page_counter") + entry = entity_registry.async_get("sensor.hl_l2340dw_duplex_unit_page_counter") assert entry assert entry.unique_id == "0123456789_duplex_unit_pages_counter" @@ -337,7 +339,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "709" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_b_w_pages") + entry = entity_registry.async_get("sensor.hl_l2340dw_b_w_pages") assert entry assert entry.unique_id == "0123456789_bw_counter" @@ -348,7 +350,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "902" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_color_pages") + entry = entity_registry.async_get("sensor.hl_l2340dw_color_pages") assert entry assert entry.unique_id == "0123456789_color_counter" @@ -360,20 +362,21 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "2019-09-24T12:14:56+00:00" assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.hl_l2340dw_last_restart") + entry = entity_registry.async_get("sensor.hl_l2340dw_last_restart") assert entry assert entry.unique_id == "0123456789_uptime" -async def test_disabled_by_default_sensors(hass: HomeAssistant) -> None: +async def test_disabled_by_default_sensors( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the disabled by default Brother sensors.""" await init_integration(hass) - registry = er.async_get(hass) state = hass.states.get("sensor.hl_l2340dw_last_restart") assert state is None - entry = registry.async_get("sensor.hl_l2340dw_last_restart") + entry = entity_registry.async_get("sensor.hl_l2340dw_last_restart") assert entry assert entry.unique_id == "0123456789_uptime" assert entry.disabled @@ -434,11 +437,12 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None: assert len(mock_update.mock_calls) == 1 -async def test_unique_id_migration(hass: HomeAssistant) -> None: +async def test_unique_id_migration( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test states of the unique_id migration.""" - registry = er.async_get(hass) - registry.async_get_or_create( + entity_registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, "0123456789_b/w_counter", @@ -448,6 +452,6 @@ async def test_unique_id_migration(hass: HomeAssistant) -> None: await init_integration(hass) - entry = registry.async_get("sensor.hl_l2340dw_b_w_counter") + entry = entity_registry.async_get("sensor.hl_l2340dw_b_w_counter") assert entry assert entry.unique_id == "0123456789_bw_counter" diff --git a/tests/components/bsblan/conftest.py b/tests/components/bsblan/conftest.py index 44d87745b3f..b7939e4cb50 100644 --- a/tests/components/bsblan/conftest.py +++ b/tests/components/bsblan/conftest.py @@ -38,25 +38,15 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: yield mock_setup -@pytest.fixture -def mock_bsblan_config_flow() -> Generator[None, MagicMock, None]: - """Return a mocked BSBLAN client.""" - with patch( - "homeassistant.components.bsblan.config_flow.BSBLAN", autospec=True - ) as bsblan_mock: - bsblan = bsblan_mock.return_value - bsblan.device.return_value = Device.parse_raw( - load_fixture("device.json", DOMAIN) - ) - bsblan.info.return_value = Info.parse_raw(load_fixture("info.json", DOMAIN)) - yield bsblan - - @pytest.fixture def mock_bsblan(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: """Return a mocked BSBLAN client.""" - with patch("homeassistant.components.bsblan.BSBLAN", autospec=True) as bsblan_mock: + with patch( + "homeassistant.components.bsblan.BSBLAN", autospec=True + ) as bsblan_mock, patch( + "homeassistant.components.bsblan.config_flow.BSBLAN", new=bsblan_mock + ): bsblan = bsblan_mock.return_value bsblan.info.return_value = Info.parse_raw(load_fixture("info.json", DOMAIN)) bsblan.device.return_value = Device.parse_raw( diff --git a/tests/components/bsblan/test_config_flow.py b/tests/components/bsblan/test_config_flow.py index dce881f2f7d..d82c32463d8 100644 --- a/tests/components/bsblan/test_config_flow.py +++ b/tests/components/bsblan/test_config_flow.py @@ -16,7 +16,7 @@ from tests.common import MockConfigEntry async def test_full_user_flow_implementation( hass: HomeAssistant, - mock_bsblan_config_flow: MagicMock, + mock_bsblan: MagicMock, mock_setup_entry: AsyncMock, ) -> None: """Test the full manual user flow from start to finish.""" @@ -52,7 +52,7 @@ async def test_full_user_flow_implementation( assert result2["result"].unique_id == format_mac("00:80:41:19:69:90") assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_bsblan_config_flow.device.mock_calls) == 1 + assert len(mock_bsblan.device.mock_calls) == 1 async def test_show_user_form(hass: HomeAssistant) -> None: @@ -68,10 +68,10 @@ async def test_show_user_form(hass: HomeAssistant) -> None: async def test_connection_error( hass: HomeAssistant, - mock_bsblan_config_flow: MagicMock, + mock_bsblan: MagicMock, ) -> None: """Test we show user form on BSBLan connection error.""" - mock_bsblan_config_flow.device.side_effect = BSBLANConnectionError + mock_bsblan.device.side_effect = BSBLANConnectionError result = await hass.config_entries.flow.async_init( DOMAIN, @@ -92,7 +92,7 @@ async def test_connection_error( async def test_user_device_exists_abort( hass: HomeAssistant, - mock_bsblan_config_flow: MagicMock, + mock_bsblan: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test we abort flow if BSBLAN device already configured.""" diff --git a/tests/components/caldav/conftest.py b/tests/components/caldav/conftest.py new file mode 100644 index 00000000000..504103afe13 --- /dev/null +++ b/tests/components/caldav/conftest.py @@ -0,0 +1,64 @@ +"""Test fixtures for caldav.""" +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.components.caldav.const import DOMAIN +from homeassistant.const import ( + CONF_PASSWORD, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, + Platform, +) + +from tests.common import MockConfigEntry + +TEST_URL = "https://example.com/url-1" +TEST_USERNAME = "username-1" +TEST_PASSWORD = "password-1" + + +@pytest.fixture(name="platforms") +def mock_platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [] + + +@pytest.fixture(autouse=True) +async def mock_patch_platforms(platforms: list[str]) -> None: + """Fixture to set up the integration.""" + with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): + yield + + +@pytest.fixture(name="calendars") +def mock_calendars() -> list[Mock]: + """Fixture to provide calendars returned by CalDAV client.""" + return [] + + +@pytest.fixture(name="dav_client", autouse=True) +def mock_dav_client(calendars: list[Mock]) -> Mock: + """Fixture to mock the DAVClient.""" + with patch( + "homeassistant.components.caldav.calendar.caldav.DAVClient" + ) as mock_client: + mock_client.return_value.principal.return_value.calendars.return_value = ( + calendars + ) + yield mock_client + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Fixture for a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_VERIFY_SSL: True, + }, + ) diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index f64cf699451..df5428121ee 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -1,17 +1,22 @@ """The tests for the webdav calendar component.""" +from collections.abc import Awaitable, Callable import datetime from http import HTTPStatus -from unittest.mock import MagicMock, Mock, patch +from typing import Any +from unittest.mock import MagicMock, Mock from caldav.objects import Event +from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -DEVICE_DATA = {"name": "Private Calendar", "device_id": "Private Calendar"} +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator EVENTS = [ """BEGIN:VCALENDAR @@ -288,64 +293,58 @@ CALDAV_CONFIG = { "url": "http://test.local", "custom_calendars": [], } +UTC = "UTC" +AMERICA_NEW_YORK = "America/New_York" +ASIA_BAGHDAD = "Asia/Baghdad" + +TEST_ENTITY = "calendar.example" +CALENDAR_NAME = "Example" @pytest.fixture -def set_tz(request): - """Set the default TZ to the one requested.""" - return request.getfixturevalue(request.param) +def platforms() -> list[Platform]: + """Fixture to set up config entry platforms.""" + return [Platform.CALENDAR] -@pytest.fixture -def utc(hass): - """Set the default TZ to UTC.""" - hass.config.set_time_zone("UTC") - - -@pytest.fixture -def new_york(hass): - """Set the default TZ to America/New_York.""" - hass.config.set_time_zone("America/New_York") - - -@pytest.fixture -def baghdad(hass): - """Set the default TZ to Asia/Baghdad.""" - hass.config.set_time_zone("Asia/Baghdad") +@pytest.fixture(name="tz") +def mock_tz() -> str | None: + """Fixture to specify the Home Assistant timezone to use during the test.""" + return None @pytest.fixture(autouse=True) -def mock_http(hass): +def set_tz(hass: HomeAssistant, tz: str | None) -> None: + """Fixture to set the default TZ to the one requested.""" + if tz is not None: + hass.config.set_time_zone(tz) + + +@pytest.fixture(autouse=True) +def mock_http(hass: HomeAssistant) -> None: """Mock the http component.""" hass.http = Mock() -@pytest.fixture -def mock_dav_client(): - """Mock the dav client.""" - patch_dav_client = patch( - "caldav.DAVClient", return_value=_mocked_dav_client("First", "Second") - ) - with patch_dav_client as dav_client: - yield dav_client +@pytest.fixture(name="calendar_names") +def mock_calendar_names() -> list[str]: + """Fixture to provide calendars returned by CalDAV client.""" + return ["Example"] -@pytest.fixture(name="calendar") -def mock_private_cal(): - """Mock a private calendar.""" - _calendar = _mock_calendar("Private") - calendars = [_calendar] - client = _mocked_dav_client(calendars=calendars) - patch_dav_client = patch("caldav.DAVClient", return_value=client) - with patch_dav_client: - yield _calendar +@pytest.fixture(name="calendars") +def mock_calendars(calendar_names: list[str]) -> list[Mock]: + """Fixture to provide calendars returned by CalDAV client.""" + return [_mock_calendar(name) for name in calendar_names] @pytest.fixture -def get_api_events(hass_client): +def get_api_events( + hass_client: ClientSessionGenerator, +) -> Callable[[str], Awaitable[dict[str, Any]]]: """Fixture to return events for a specific calendar using the API.""" - async def api_call(entity_id): + async def api_call(entity_id: str) -> dict[str, Any]: client = await hass_client() response = await client.get( # The start/end times are arbitrary since they are ignored by `_mock_calendar` @@ -358,28 +357,16 @@ def get_api_events(hass_client): return api_call -def _local_datetime(hours, minutes): +def _local_datetime(hours: int, minutes: int) -> datetime.datetime: """Build a datetime object for testing in the correct timezone.""" return dt_util.as_local(datetime.datetime(2017, 11, 27, hours, minutes, 0)) -def _mocked_dav_client(*names, calendars=None): - """Mock requests.get invocations.""" - if calendars is None: - calendars = [_mock_calendar(name) for name in names] - principal = Mock() - principal.calendars = MagicMock(return_value=calendars) - - client = Mock() - client.principal = MagicMock(return_value=principal) - return client - - -def _mock_calendar(name, supported_components=None): +def _mock_calendar(name: str, supported_components: list[str] | None = None) -> Mock: calendar = Mock() events = [] for idx, event in enumerate(EVENTS): - events.append(Event(None, "%d.ics" % idx, event, calendar, str(idx))) + events.append(Event(None, f"{idx}.ics", event, calendar, str(idx))) if supported_components is None: supported_components = ["VEVENT"] calendar.search = MagicMock(return_value=events) @@ -388,77 +375,78 @@ def _mock_calendar(name, supported_components=None): return calendar -async def test_setup_component(hass: HomeAssistant, mock_dav_client) -> None: - """Test setup component with calendars.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() - - state = hass.states.get("calendar.first") - assert state.name == "First" - state = hass.states.get("calendar.second") - assert state.name == "Second" +@pytest.fixture(name="config") +def mock_config() -> dict[str, Any]: + """Fixture to provide calendar configuration.yaml.""" + return {} -async def test_setup_component_with_no_calendar_matching( - hass: HomeAssistant, mock_dav_client +@pytest.fixture(name="setup_platform_cb") +async def mock_setup_platform_cb( + hass: HomeAssistant, config: dict[str, Any] +) -> Callable[[], Awaitable[None]]: + """Fixture that returns a function to setup the calendar platform.""" + + async def _run() -> None: + assert await async_setup_component( + hass, "calendar", {"calendar": {**CALDAV_CONFIG, **config}} + ) + await hass.async_block_till_done() + + return _run + + +@pytest.mark.parametrize( + ("calendar_names", "config", "expected_entities"), + [ + (["First", "Second"], {}, ["calendar.first", "calendar.second"]), + ( + ["First", "Second"], + {"calendars": ["none"]}, + [], + ), + (["First", "Second"], {"calendars": ["Second"]}, ["calendar.second"]), + ( + ["First", "Second"], + { + "custom_calendars": { + "name": "HomeOffice", + "calendar": "Second", + "search": "HomeOffice", + }, + }, + ["calendar.second_homeoffice"], + ), + ], + ids=("config", "no_match", "match", "custom"), +) +async def test_setup_component_config( + hass: HomeAssistant, + config: dict[str, Any], + expected_entities: list[str], + setup_platform_cb: Callable[[], Awaitable[None]], ) -> None: """Test setup component with wrong calendar.""" - config = dict(CALDAV_CONFIG) - config["calendars"] = ["none"] + await setup_platform_cb() - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() - - all_calendar_states = hass.states.async_entity_ids("calendar") - assert not all_calendar_states + all_calendar_entities = hass.states.async_entity_ids("calendar") + assert all_calendar_entities == expected_entities -async def test_setup_component_with_a_calendar_match( - hass: HomeAssistant, mock_dav_client +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(17, 45)) +async def test_ongoing_event( + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: - """Test setup component with right calendar.""" - config = dict(CALDAV_CONFIG) - config["calendars"] = ["Second"] - - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() - - all_calendar_states = hass.states.async_entity_ids("calendar") - assert len(all_calendar_states) == 1 - state = hass.states.get("calendar.second") - assert state.name == "Second" - - -async def test_setup_component_with_one_custom_calendar( - hass: HomeAssistant, mock_dav_client -) -> None: - """Test setup component with custom calendars.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "HomeOffice", "calendar": "Second", "search": "HomeOffice"} - ] - - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() - - all_calendar_states = hass.states.async_entity_ids("calendar") - assert len(all_calendar_states) == 1 - state = hass.states.get("calendar.second_homeoffice") - assert state.name == "HomeOffice" - - -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(17, 45)) -async def test_ongoing_event(mock_now, hass: HomeAssistant, calendar, set_tz) -> None: """Test that the ongoing event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_ON assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a normal event", "all_day": False, "offset_reached": False, @@ -469,20 +457,20 @@ async def test_ongoing_event(mock_now, hass: HomeAssistant, calendar, set_tz) -> } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(17, 30)) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(17, 30)) async def test_just_ended_event( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the next ongoing event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_ON assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a normal event", "all_day": False, "offset_reached": False, @@ -493,20 +481,20 @@ async def test_just_ended_event( } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(17, 00)) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(17, 00)) async def test_ongoing_event_different_tz( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the ongoing event with another timezone is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_ON assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "Enjoy the sun", "all_day": False, "offset_reached": False, @@ -517,20 +505,20 @@ async def test_ongoing_event_different_tz( } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(19, 10)) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(19, 10)) async def test_ongoing_floating_event_returned( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that floating events without timezones work.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_ON assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a floating Event", "all_day": False, "offset_reached": False, @@ -541,20 +529,20 @@ async def test_ongoing_floating_event_returned( } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(8, 30)) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(8, 30)) async def test_ongoing_event_with_offset( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the offset is taken into account.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_OFF assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is an offset event", "all_day": False, "offset_reached": True, @@ -565,23 +553,36 @@ async def test_ongoing_event_with_offset( } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(12, 00)) -async def test_matching_filter(mock_now, hass: HomeAssistant, calendar, set_tz) -> None: +@pytest.mark.parametrize( + ("tz", "config"), + [ + ( + UTC, + { + "custom_calendars": [ + { + "name": CALENDAR_NAME, + "calendar": CALENDAR_NAME, + "search": "This is a normal event", + } + ] + }, + ) + ], +) +@freeze_time(_local_datetime(12, 00)) +async def test_matching_filter( + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] +) -> None: """Test that the matching event is returned.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": "This is a normal event"} - ] + await setup_platform_cb() - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() - - state = hass.states.get("calendar.private_private") - assert state.name == calendar.name + state = hass.states.get("calendar.example_example") + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_OFF assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a normal event", "all_day": False, "offset_reached": False, @@ -592,25 +593,37 @@ async def test_matching_filter(mock_now, hass: HomeAssistant, calendar, set_tz) } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(12, 00)) +@pytest.mark.parametrize( + ("tz", "config"), + [ + ( + UTC, + { + "custom_calendars": [ + { + "name": CALENDAR_NAME, + "calendar": CALENDAR_NAME, + "search": r".*rainy", + } + ] + }, + ) + ], +) +@freeze_time(_local_datetime(12, 00)) async def test_matching_filter_real_regexp( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the event matching the regexp is returned.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": r".*rainy"} - ] - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private_private") - assert state.name == calendar.name + state = hass.states.get("calendar.example_example") + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_OFF assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a normal event", "all_day": False, "offset_reached": False, @@ -621,138 +634,137 @@ async def test_matching_filter_real_regexp( } -@patch("homeassistant.util.dt.now", return_value=_local_datetime(20, 00)) +@pytest.mark.parametrize( + "config", + [ + { + "custom_calendars": [ + { + "name": CALENDAR_NAME, + "calendar": CALENDAR_NAME, + "search": "This is a normal event", + } + ] + } + ], +) +@freeze_time(_local_datetime(20, 00)) async def test_filter_matching_past_event( - mock_now, hass: HomeAssistant, calendar + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the matching past event is not returned.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": "This is a normal event"} - ] - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private_private") - assert state.name == calendar.name + state = hass.states.get("calendar.example_example") + assert state + assert state.name == CALENDAR_NAME assert state.state == "off" + assert dict(state.attributes) == { + "friendly_name": CALENDAR_NAME, + "offset_reached": False, + } -@patch("homeassistant.util.dt.now", return_value=_local_datetime(12, 00)) +@pytest.mark.parametrize( + "config", + [ + { + "custom_calendars": [ + { + "name": CALENDAR_NAME, + "calendar": CALENDAR_NAME, + "search": "This is a non-existing event", + } + ] + } + ], +) +@freeze_time(_local_datetime(12, 00)) async def test_no_result_with_filtering( - mock_now, hass: HomeAssistant, calendar + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that nothing is returned since nothing matches.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - { - "name": "Private", - "calendar": "Private", - "search": "This is a non-existing event", - } - ] + await setup_platform_cb() - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() - - state = hass.states.get("calendar.private_private") - assert state.name == calendar.name + state = hass.states.get("calendar.example_example") + assert state + assert state.name == CALENDAR_NAME assert state.state == "off" + assert dict(state.attributes) == { + "friendly_name": CALENDAR_NAME, + "offset_reached": False, + } -async def _day_event_returned(hass, calendar, config, date_time): - with patch("homeassistant.util.dt.now", return_value=date_time): - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() - - state = hass.states.get("calendar.private_private") - assert state.name == calendar.name - assert state.state == STATE_ON - assert dict(state.attributes) == { - "friendly_name": "Private", - "message": "This is an all day event", - "all_day": True, - "offset_reached": False, - "start_time": "2017-11-27 00:00:00", - "end_time": "2017-11-28 00:00:00", - "location": "Hamburg", - "description": "What a beautiful day", - } - - -@pytest.mark.parametrize("set_tz", ["utc", "new_york", "baghdad"], indirect=True) -async def test_all_day_event_returned_early( - hass: HomeAssistant, calendar, set_tz +@pytest.mark.parametrize( + ("tz", "target_datetime"), + [ + # Early + (UTC, datetime.datetime(2017, 11, 27, 0, 30)), + (AMERICA_NEW_YORK, datetime.datetime(2017, 11, 27, 0, 30)), + (ASIA_BAGHDAD, datetime.datetime(2017, 11, 27, 0, 30)), + # Mid + (UTC, datetime.datetime(2017, 11, 27, 12, 30)), + (AMERICA_NEW_YORK, datetime.datetime(2017, 11, 27, 12, 30)), + (ASIA_BAGHDAD, datetime.datetime(2017, 11, 27, 12, 30)), + # Late + (UTC, datetime.datetime(2017, 11, 27, 23, 30)), + (AMERICA_NEW_YORK, datetime.datetime(2017, 11, 27, 23, 30)), + (ASIA_BAGHDAD, datetime.datetime(2017, 11, 27, 23, 30)), + ], +) +async def test_all_day_event( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + target_datetime: datetime.datetime, ) -> None: """Test that the event lasting the whole day is returned, if it's early in the local day.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": ".*"} - ] - - await _day_event_returned( + freezer.move_to(target_datetime.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)) + assert await async_setup_component( hass, - calendar, - config, - datetime.datetime(2017, 11, 27, 0, 30).replace( - tzinfo=dt_util.DEFAULT_TIME_ZONE - ), + "calendar", + { + "calendar": { + **CALDAV_CONFIG, + "custom_calendars": [ + {"name": CALENDAR_NAME, "calendar": CALENDAR_NAME, "search": ".*"} + ], + } + }, ) - - -@pytest.mark.parametrize("set_tz", ["utc", "new_york", "baghdad"], indirect=True) -async def test_all_day_event_returned_mid( - hass: HomeAssistant, calendar, set_tz -) -> None: - """Test that the event lasting the whole day is returned, if it's in the middle of the local day.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": ".*"} - ] - - await _day_event_returned( - hass, - calendar, - config, - datetime.datetime(2017, 11, 27, 12, 30).replace( - tzinfo=dt_util.DEFAULT_TIME_ZONE - ), - ) - - -@pytest.mark.parametrize("set_tz", ["utc", "new_york", "baghdad"], indirect=True) -async def test_all_day_event_returned_late( - hass: HomeAssistant, calendar, set_tz -) -> None: - """Test that the event lasting the whole day is returned, if it's late in the local day.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": ".*"} - ] - - await _day_event_returned( - hass, - calendar, - config, - datetime.datetime(2017, 11, 27, 23, 30).replace( - tzinfo=dt_util.DEFAULT_TIME_ZONE - ), - ) - - -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(21, 45)) -async def test_event_rrule(mock_now, hass: HomeAssistant, calendar, set_tz) -> None: - """Test that the future recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) await hass.async_block_till_done() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get("calendar.example_example") + assert state + assert state.name == CALENDAR_NAME + assert state.state == STATE_ON + assert dict(state.attributes) == { + "friendly_name": CALENDAR_NAME, + "message": "This is an all day event", + "all_day": True, + "offset_reached": False, + "start_time": "2017-11-27 00:00:00", + "end_time": "2017-11-28 00:00:00", + "location": "Hamburg", + "description": "What a beautiful day", + } + + +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(21, 45)) +async def test_event_rrule( + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] +) -> None: + """Test that the future recurring event is returned.""" + await setup_platform_cb() + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_OFF assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a recurring event", "all_day": False, "offset_reached": False, @@ -763,20 +775,20 @@ async def test_event_rrule(mock_now, hass: HomeAssistant, calendar, set_tz) -> N } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(22, 15)) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(22, 15)) async def test_event_rrule_ongoing( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the current recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_ON assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a recurring event", "all_day": False, "offset_reached": False, @@ -787,20 +799,20 @@ async def test_event_rrule_ongoing( } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(22, 45)) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(22, 45)) async def test_event_rrule_duration( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the future recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_OFF assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a recurring event with a duration", "all_day": False, "offset_reached": False, @@ -811,20 +823,20 @@ async def test_event_rrule_duration( } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(23, 15)) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(23, 15)) async def test_event_rrule_duration_ongoing( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the ongoing recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_ON assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a recurring event with a duration", "all_day": False, "offset_reached": False, @@ -835,20 +847,20 @@ async def test_event_rrule_duration_ongoing( } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(23, 37)) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(23, 37)) async def test_event_rrule_endless( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the endless recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_OFF assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a recurring event that never ends", "all_day": False, "offset_reached": False, @@ -859,95 +871,76 @@ async def test_event_rrule_endless( } -async def _event_rrule_all_day(hass, calendar, config, date_time): - with patch("homeassistant.util.dt.now", return_value=date_time): - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() - - state = hass.states.get("calendar.private_private") - assert state.name == calendar.name - assert state.state == STATE_ON - assert dict(state.attributes) == { - "friendly_name": "Private", - "message": "This is a recurring all day event", - "all_day": True, - "offset_reached": False, - "start_time": "2016-12-01 00:00:00", - "end_time": "2016-12-02 00:00:00", - "location": "Hamburg", - "description": "Groundhog Day", - } - - -@pytest.mark.parametrize("set_tz", ["utc", "new_york", "baghdad"], indirect=True) -async def test_event_rrule_all_day_early(hass: HomeAssistant, calendar, set_tz) -> None: - """Test that the recurring all day event is returned early in the local day, and not on the first occurrence.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": ".*"} - ] - - await _event_rrule_all_day( - hass, - calendar, - config, - datetime.datetime(2016, 12, 1, 0, 30).replace(tzinfo=dt_util.DEFAULT_TIME_ZONE), - ) - - -@pytest.mark.parametrize("set_tz", ["utc", "new_york", "baghdad"], indirect=True) -async def test_event_rrule_all_day_mid(hass: HomeAssistant, calendar, set_tz) -> None: - """Test that the recurring all day event is returned in the middle of the local day, and not on the first occurrence.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": ".*"} - ] - - await _event_rrule_all_day( - hass, - calendar, - config, - datetime.datetime(2016, 12, 1, 17, 30).replace( - tzinfo=dt_util.DEFAULT_TIME_ZONE - ), - ) - - -@pytest.mark.parametrize("set_tz", ["utc", "new_york", "baghdad"], indirect=True) -async def test_event_rrule_all_day_late(hass: HomeAssistant, calendar, set_tz) -> None: - """Test that the recurring all day event is returned late in the local day, and not on the first occurrence.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": ".*"} - ] - - await _event_rrule_all_day( - hass, - calendar, - config, - datetime.datetime(2016, 12, 1, 23, 30).replace( - tzinfo=dt_util.DEFAULT_TIME_ZONE - ), - ) - - -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch( - "homeassistant.util.dt.now", - return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 0, 15)), +@pytest.mark.parametrize( + ("tz", "target_datetime"), + [ + # Early + (UTC, datetime.datetime(2016, 12, 1, 0, 30)), + (AMERICA_NEW_YORK, datetime.datetime(2016, 12, 1, 0, 30)), + (ASIA_BAGHDAD, datetime.datetime(2016, 12, 1, 0, 30)), + # Mid + (UTC, datetime.datetime(2016, 12, 1, 17, 30)), + (AMERICA_NEW_YORK, datetime.datetime(2016, 12, 1, 17, 30)), + (ASIA_BAGHDAD, datetime.datetime(2016, 12, 1, 17, 30)), + # Late + (UTC, datetime.datetime(2016, 12, 1, 23, 30)), + (AMERICA_NEW_YORK, datetime.datetime(2016, 12, 1, 23, 30)), + (ASIA_BAGHDAD, datetime.datetime(2016, 12, 1, 23, 30)), + ], ) -async def test_event_rrule_hourly_on_first( - mock_now, hass: HomeAssistant, calendar, set_tz +async def test_event_rrule_all_day_early( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + target_datetime: datetime.datetime, ) -> None: - """Test that the endless recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) + """Test that the recurring all day event is returned early in the local day, and not on the first occurrence.""" + freezer.move_to(target_datetime.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)) + assert await async_setup_component( + hass, + "calendar", + { + "calendar": { + **CALDAV_CONFIG, + "custom_calendars": { + "name": CALENDAR_NAME, + "calendar": CALENDAR_NAME, + "search": ".*", + }, + }, + }, + ) await hass.async_block_till_done() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get("calendar.example_example") + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_ON assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, + "message": "This is a recurring all day event", + "all_day": True, + "offset_reached": False, + "start_time": "2016-12-01 00:00:00", + "end_time": "2016-12-02 00:00:00", + "location": "Hamburg", + "description": "Groundhog Day", + } + + +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(dt_util.as_local(datetime.datetime(2015, 11, 27, 0, 15))) +async def test_event_rrule_hourly_on_first( + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] +) -> None: + """Test that the endless recurring event is returned.""" + await setup_platform_cb() + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME + assert state.state == STATE_ON + assert dict(state.attributes) == { + "friendly_name": CALENDAR_NAME, "message": "This is an hourly recurring event", "all_day": False, "offset_reached": False, @@ -958,23 +951,20 @@ async def test_event_rrule_hourly_on_first( } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch( - "homeassistant.util.dt.now", - return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 11, 15)), -) +@pytest.mark.parametrize("tz", ["UTC"]) +@freeze_time(dt_util.as_local(datetime.datetime(2015, 11, 27, 11, 15))) async def test_event_rrule_hourly_on_last( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the endless recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_ON assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is an hourly recurring event", "all_day": False, "offset_reached": False, @@ -985,77 +975,67 @@ async def test_event_rrule_hourly_on_last( } -@patch( - "homeassistant.util.dt.now", - return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 0, 45)), +@pytest.mark.parametrize( + ("target_datetime"), + [ + datetime.datetime(2015, 11, 27, 0, 45), + datetime.datetime(2015, 11, 27, 11, 45), + datetime.datetime(2015, 11, 27, 12, 15), + ], ) -async def test_event_rrule_hourly_off_first( - mock_now, hass: HomeAssistant, calendar +async def test_event_rrule_hourly( + hass: HomeAssistant, + setup_platform_cb: Callable[[], Awaitable[None]], + freezer: FrozenDateTimeFactory, + target_datetime: datetime.datetime, ) -> None: """Test that the endless recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + freezer.move_to(dt_util.as_local(target_datetime)) + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_OFF -@patch( - "homeassistant.util.dt.now", - return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 11, 45)), -) -async def test_event_rrule_hourly_off_last( - mock_now, hass: HomeAssistant, calendar +async def test_get_events( + hass: HomeAssistant, + get_api_events: Callable[[str], Awaitable[dict[str, Any]]], + setup_platform_cb: Callable[[], Awaitable[None]], + calendars: list[Mock], ) -> None: - """Test that the endless recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() - - state = hass.states.get("calendar.private") - assert state.name == calendar.name - assert state.state == STATE_OFF - - -@patch( - "homeassistant.util.dt.now", - return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 12, 15)), -) -async def test_event_rrule_hourly_ended( - mock_now, hass: HomeAssistant, calendar -) -> None: - """Test that the endless recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() - - state = hass.states.get("calendar.private") - assert state.name == calendar.name - assert state.state == STATE_OFF - - -async def test_get_events(hass: HomeAssistant, calendar, get_api_events) -> None: """Test that all events are returned on API.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - events = await get_api_events("calendar.private") + events = await get_api_events(TEST_ENTITY) assert len(events) == 18 - assert calendar.call + assert calendars[0].call +@pytest.mark.parametrize( + "config", + [ + { + "custom_calendars": [ + { + "name": CALENDAR_NAME, + "calendar": CALENDAR_NAME, + "search": "This is a normal event", + } + ] + } + ], +) async def test_get_events_custom_calendars( - hass: HomeAssistant, calendar, get_api_events + hass: HomeAssistant, + get_api_events: Callable[[str], Awaitable[dict[str, Any]]], + setup_platform_cb: Callable[[], Awaitable[None]], ) -> None: """Test that only searched events are returned on API.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": "This is a normal event"} - ] + await setup_platform_cb() - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() - - events = await get_api_events("calendar.private_private") + events = await get_api_events("calendar.example_example") assert events == [ { "end": {"dateTime": "2017-11-27T10:00:00-08:00"}, @@ -1070,38 +1050,92 @@ async def test_get_events_custom_calendars( ] -async def test_calendar_components( - hass: HomeAssistant, -) -> None: +@pytest.mark.parametrize( + ("calendars"), + [ + [ + _mock_calendar("Calendar 1", supported_components=["VEVENT"]), + _mock_calendar("Calendar 2", supported_components=["VEVENT", "VJOURNAL"]), + _mock_calendar("Calendar 3", supported_components=["VTODO"]), + _mock_calendar("Calendar 4", supported_components=[]), + ] + ], +) +async def test_calendar_components(hass: HomeAssistant) -> None: """Test that only calendars that support events are created.""" - calendars = [ - _mock_calendar("Calendar 1", supported_components=["VEVENT"]), - _mock_calendar("Calendar 2", supported_components=["VEVENT", "VJOURNAL"]), - _mock_calendar("Calendar 3", supported_components=["VTODO"]), - # Fallback to allow when no components are supported to be conservative - _mock_calendar("Calendar 4", supported_components=[]), - ] - with patch( - "homeassistant.components.caldav.calendar.caldav.DAVClient", - return_value=_mocked_dav_client(calendars=calendars), - ): - assert await async_setup_component( - hass, "calendar", {"calendar": CALDAV_CONFIG} - ) - await hass.async_block_till_done() + + assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) + await hass.async_block_till_done() state = hass.states.get("calendar.calendar_1") - assert state.name == "Calendar 1" - assert state.state == STATE_OFF + assert state state = hass.states.get("calendar.calendar_2") - assert state.name == "Calendar 2" - assert state.state == STATE_OFF + assert state # No entity created for To-do only component state = hass.states.get("calendar.calendar_3") assert not state + # No entity created when no components exist state = hass.states.get("calendar.calendar_4") - assert state.name == "Calendar 4" - assert state.state == STATE_OFF + assert not state + + +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(17, 30)) +async def test_setup_config_entry( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test a calendar entity from a config entry.""" + config_entry.add_to_hass(hass) + await config_entry.async_setup(hass) + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME + assert state.state == STATE_ON + assert dict(state.attributes) == { + "friendly_name": CALENDAR_NAME, + "message": "This is an all day event", + "all_day": True, + "start_time": "2017-11-27 00:00:00", + "end_time": "2017-11-28 00:00:00", + "location": "Hamburg", + "description": "What a beautiful day", + } + + +@pytest.mark.parametrize( + ("calendars"), + [ + [ + _mock_calendar("Calendar 1", supported_components=["VEVENT"]), + _mock_calendar("Calendar 2", supported_components=["VEVENT", "VJOURNAL"]), + _mock_calendar("Calendar 3", supported_components=["VTODO"]), + _mock_calendar("Calendar 4", supported_components=[]), + ] + ], +) +async def test_config_entry_supported_components( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test that calendars are only created for VEVENT types when using a config entry.""" + config_entry.add_to_hass(hass) + await config_entry.async_setup(hass) + + state = hass.states.get("calendar.calendar_1") + assert state + + state = hass.states.get("calendar.calendar_2") + assert state + + # No entity created for To-do only component + state = hass.states.get("calendar.calendar_3") + assert not state + + # No entity created when no components exist + state = hass.states.get("calendar.calendar_4") + assert not state diff --git a/tests/components/caldav/test_config_flow.py b/tests/components/caldav/test_config_flow.py new file mode 100644 index 00000000000..6af7d5c670c --- /dev/null +++ b/tests/components/caldav/test_config_flow.py @@ -0,0 +1,284 @@ +"""Test the CalDAV config flow.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch + +from caldav.lib.error import AuthorizationError, DAVError +import pytest +import requests + +from homeassistant import config_entries +from homeassistant.components.caldav.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import TEST_PASSWORD, TEST_URL, TEST_USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + f"homeassistant.components.{DOMAIN}.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +async def test_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test successful config flow setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_VERIFY_SSL: False, + }, + ) + await hass.async_block_till_done() + + assert result2.get("type") == FlowResultType.CREATE_ENTRY + assert result2.get("title") == TEST_USERNAME + assert result2.get("data") == { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_VERIFY_SSL: False, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (Exception(), "unknown"), + (requests.ConnectionError(), "cannot_connect"), + (DAVError(), "cannot_connect"), + (AuthorizationError(reason="Unauthorized"), "invalid_auth"), + (AuthorizationError(reason="Other"), "cannot_connect"), + ], +) +async def test_caldav_client_error( + hass: HomeAssistant, + side_effect: Exception, + expected_error: str, + dav_client: Mock, +) -> None: + """Test CalDav client errors during configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + dav_client.return_value.principal.side_effect = side_effect + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": expected_error} + + +async def test_reauth_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test reauthentication configuration flow.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: "password-2", + }, + ) + await hass.async_block_till_done() + + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "reauth_successful" + + # Verify updated configuration entry + assert dict(config_entry.data) == { + CONF_URL: "https://example.com/url-1", + CONF_USERNAME: "username-1", + CONF_PASSWORD: "password-2", + CONF_VERIFY_SSL: True, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_failure( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + config_entry: MockConfigEntry, + dav_client: Mock, +) -> None: + """Test a failure during reauthentication configuration flow.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + dav_client.return_value.principal.side_effect = DAVError + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: "password-2", + }, + ) + await hass.async_block_till_done() + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "cannot_connect"} + + # Complete the form and it succeeds this time + dav_client.return_value.principal.side_effect = None + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: "password-3", + }, + ) + await hass.async_block_till_done() + + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "reauth_successful" + + # Verify updated configuration entry + assert dict(config_entry.data) == { + CONF_URL: "https://example.com/url-1", + CONF_USERNAME: "username-1", + CONF_PASSWORD: "password-3", + CONF_VERIFY_SSL: True, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("user_input"), + [ + { + CONF_URL: f"{TEST_URL}/different-path", + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + { + CONF_URL: TEST_URL, + CONF_USERNAME: f"{TEST_USERNAME}-different-user", + CONF_PASSWORD: TEST_PASSWORD, + }, + ], +) +async def test_multiple_config_entries( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + config_entry: MockConfigEntry, + user_input: dict[str, str], +) -> None: + """Test multiple configuration entries with unique settings.""" + + config_entry.add_to_hass(hass) + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + await hass.async_block_till_done() + + assert result2.get("type") == FlowResultType.CREATE_ENTRY + assert result2.get("title") == user_input[CONF_USERNAME] + assert result2.get("data") == { + **user_input, + CONF_VERIFY_SSL: True, + } + assert len(mock_setup_entry.mock_calls) == 2 + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 2 + + +@pytest.mark.parametrize( + ("user_input"), + [ + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: f"{TEST_PASSWORD}-different", + }, + ], +) +async def test_duplicate_config_entries( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + config_entry: MockConfigEntry, + user_input: dict[str, str], +) -> None: + """Test multiple configuration entries with the same settings.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + await hass.async_block_till_done() + + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "already_configured" diff --git a/tests/components/caldav/test_init.py b/tests/components/caldav/test_init.py new file mode 100644 index 00000000000..8e832e24d2d --- /dev/null +++ b/tests/components/caldav/test_init.py @@ -0,0 +1,72 @@ +"""Unit tests for the CalDav integration.""" + +from unittest.mock import patch + +from caldav.lib.error import AuthorizationError, DAVError +import pytest +import requests + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +async def mock_add_to_hass(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture to add the ConfigEntry.""" + config_entry.add_to_hass(hass) + + +async def test_load_unload( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading of the config entry.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + + with patch("homeassistant.components.caldav.config_flow.caldav.DAVClient"): + await config_entry.async_setup(hass) + + assert config_entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + assert config_entry.state == ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("side_effect", "expected_state", "expected_flows"), + [ + (Exception(), ConfigEntryState.SETUP_ERROR, []), + (requests.ConnectionError(), ConfigEntryState.SETUP_RETRY, []), + (DAVError(), ConfigEntryState.SETUP_RETRY, []), + ( + AuthorizationError(reason="Unauthorized"), + ConfigEntryState.SETUP_ERROR, + ["reauth_confirm"], + ), + (AuthorizationError(reason="Other"), ConfigEntryState.SETUP_ERROR, []), + ], +) +async def test_client_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + side_effect: Exception, + expected_state: ConfigEntryState, + expected_flows: list[str], +) -> None: + """Test CalDAV client failures in setup.""" + + assert config_entry.state == ConfigEntryState.NOT_LOADED + + with patch( + "homeassistant.components.caldav.config_flow.caldav.DAVClient" + ) as mock_client: + mock_client.return_value.principal.side_effect = side_effect + await config_entry.async_setup(hass) + await hass.async_block_till_done() + + assert config_entry.state == expected_state + + flows = hass.config_entries.flow.async_progress() + assert [flow.get("step_id") for flow in flows] == expected_flows diff --git a/tests/components/caldav/test_todo.py b/tests/components/caldav/test_todo.py new file mode 100644 index 00000000000..6e92f211463 --- /dev/null +++ b/tests/components/caldav/test_todo.py @@ -0,0 +1,665 @@ +"""The tests for the webdav todo component.""" +from typing import Any +from unittest.mock import MagicMock, Mock + +from caldav.lib.error import DAVError, NotFoundError +from caldav.objects import Todo +import pytest + +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator + +CALENDAR_NAME = "My Tasks" +ENTITY_NAME = "My tasks" +TEST_ENTITY = "todo.my_tasks" +SUPPORTED_FEATURES = 119 + +TODO_NO_STATUS = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VTODO +UID:1 +DTSTAMP:20231125T000000Z +SUMMARY:Milk +END:VTODO +END:VCALENDAR""" + +TODO_NEEDS_ACTION = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VTODO +UID:2 +DTSTAMP:20171125T000000Z +SUMMARY:Cheese +STATUS:NEEDS-ACTION +END:VTODO +END:VCALENDAR""" + +RESULT_ITEM = { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", +} + +TODO_COMPLETED = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VTODO +UID:3 +DTSTAMP:20231125T000000Z +SUMMARY:Wine +STATUS:COMPLETED +END:VTODO +END:VCALENDAR""" + + +TODO_NO_SUMMARY = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VTODO +UID:4 +DTSTAMP:20171126T000000Z +STATUS:NEEDS-ACTION +END:VTODO +END:VCALENDAR""" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set up config entry platforms.""" + return [Platform.TODO] + + +@pytest.fixture(autouse=True) +def set_tz(hass: HomeAssistant) -> None: + """Fixture to set timezone with fixed offset year round.""" + hass.config.set_time_zone("America/Regina") + + +@pytest.fixture(name="todos") +def mock_todos() -> list[str]: + """Fixture to return VTODO objects for the calendar.""" + return [] + + +@pytest.fixture(name="supported_components") +def mock_supported_components() -> list[str]: + """Fixture to set supported components of the calendar.""" + return ["VTODO"] + + +@pytest.fixture(name="calendar") +def mock_calendar(supported_components: list[str]) -> Mock: + """Fixture to create the primary calendar for the test.""" + calendar = Mock() + calendar.search = MagicMock(return_value=[]) + calendar.name = CALENDAR_NAME + calendar.get_supported_components = MagicMock(return_value=supported_components) + return calendar + + +def create_todo(calendar: Mock, idx: str, ics: str) -> Todo: + """Create a caldav Todo object.""" + return Todo(client=None, url=f"{idx}.ics", data=ics, parent=calendar, id=idx) + + +@pytest.fixture(autouse=True) +def mock_search_items(calendar: Mock, todos: list[str]) -> None: + """Fixture to add search results to the test calendar.""" + calendar.search.return_value = [ + create_todo(calendar, str(idx), item) for idx, item in enumerate(todos) + ] + + +@pytest.fixture(name="calendars") +def mock_calendars(calendar: Mock) -> list[Mock]: + """Fixture to create calendars for the test.""" + return [calendar] + + +@pytest.fixture(autouse=True) +async def mock_add_to_hass( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Fixture to add the ConfigEntry.""" + config_entry.add_to_hass(hass) + + +@pytest.mark.parametrize( + ("todos", "expected_state"), + [ + ([], "0"), + ( + [TODO_NEEDS_ACTION], + "1", + ), + ( + [TODO_NO_STATUS], + "1", + ), + ([TODO_COMPLETED], "0"), + ([TODO_NO_STATUS, TODO_NEEDS_ACTION, TODO_COMPLETED], "2"), + ([TODO_NO_SUMMARY], "0"), + ], + ids=( + "empty", + "needs_action", + "no_status", + "completed", + "all", + "no_summary", + ), +) +async def test_todo_list_state( + hass: HomeAssistant, + config_entry: MockConfigEntry, + expected_state: str, +) -> None: + """Test a calendar entity from a config entry.""" + await config_entry.async_setup(hass) + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == ENTITY_NAME + assert state.state == expected_state + assert dict(state.attributes) == { + "friendly_name": ENTITY_NAME, + "supported_features": SUPPORTED_FEATURES, + } + + +@pytest.mark.parametrize( + ("supported_components", "has_entity"), + [([], False), (["VTODO"], True), (["VEVENT"], False), (["VEVENT", "VTODO"], True)], +) +async def test_supported_components( + hass: HomeAssistant, + config_entry: MockConfigEntry, + has_entity: bool, +) -> None: + """Test a calendar supported components matches VTODO.""" + await config_entry.async_setup(hass) + + state = hass.states.get(TEST_ENTITY) + assert (state is not None) == has_entity + + +@pytest.mark.parametrize( + ("item_data", "expcted_save_args", "expected_item"), + [ + ( + {}, + {"status": "NEEDS-ACTION", "summary": "Cheese"}, + RESULT_ITEM, + ), + ( + {"due_date": "2023-11-18"}, + {"status": "NEEDS-ACTION", "summary": "Cheese", "due": "20231118"}, + {**RESULT_ITEM, "due": "2023-11-18"}, + ), + ( + {"due_datetime": "2023-11-18T08:30:00-06:00"}, + {"status": "NEEDS-ACTION", "summary": "Cheese", "due": "20231118T143000Z"}, + {**RESULT_ITEM, "due": "2023-11-18T08:30:00-06:00"}, + ), + ( + {"description": "Make sure to get Swiss"}, + { + "status": "NEEDS-ACTION", + "summary": "Cheese", + "description": "Make sure to get Swiss", + }, + {**RESULT_ITEM, "description": "Make sure to get Swiss"}, + ), + ], + ids=[ + "summary", + "due_date", + "due_datetime", + "description", + ], +) +async def test_add_item( + hass: HomeAssistant, + config_entry: MockConfigEntry, + dav_client: Mock, + calendar: Mock, + item_data: dict[str, Any], + expcted_save_args: dict[str, Any], + expected_item: dict[str, Any], +) -> None: + """Test adding an item to the list.""" + calendar.search.return_value = [] + await config_entry.async_setup(hass) + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + # Simulate return value for the state update after the service call + calendar.search.return_value = [create_todo(calendar, "2", TODO_NEEDS_ACTION)] + + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "Cheese", **item_data}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + assert calendar.save_todo.call_args + assert calendar.save_todo.call_args.kwargs == expcted_save_args + + # Verify state was updated + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + +async def test_add_item_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendar: Mock, +) -> None: + """Test failure when adding an item to the list.""" + await config_entry.async_setup(hass) + + calendar.save_todo.side_effect = DAVError() + + with pytest.raises(HomeAssistantError, match="CalDAV save error"): + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "Cheese"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("update_data", "expected_ics", "expected_state", "expected_item"), + [ + ( + {"rename": "Swiss Cheese"}, + ["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"], + "1", + {**RESULT_ITEM, "summary": "Swiss Cheese"}, + ), + ( + {"status": "needs_action"}, + ["SUMMARY:Cheese", "STATUS:NEEDS-ACTION"], + "1", + RESULT_ITEM, + ), + ( + {"status": "completed"}, + ["SUMMARY:Cheese", "STATUS:COMPLETED"], + "0", + {**RESULT_ITEM, "status": "completed"}, + ), + ( + {"rename": "Swiss Cheese", "status": "needs_action"}, + ["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"], + "1", + {**RESULT_ITEM, "summary": "Swiss Cheese"}, + ), + ( + {"due_date": "2023-11-18"}, + ["SUMMARY:Cheese", "DUE:20231118"], + "1", + {**RESULT_ITEM, "due": "2023-11-18"}, + ), + ( + {"due_datetime": "2023-11-18T08:30:00-06:00"}, + ["SUMMARY:Cheese", "DUE:20231118T143000Z"], + "1", + {**RESULT_ITEM, "due": "2023-11-18T08:30:00-06:00"}, + ), + ( + {"description": "Make sure to get Swiss"}, + ["SUMMARY:Cheese", "DESCRIPTION:Make sure to get Swiss"], + "1", + {**RESULT_ITEM, "description": "Make sure to get Swiss"}, + ), + ], + ids=[ + "rename", + "status_needs_action", + "status_completed", + "rename_status", + "due_date", + "due_datetime", + "description", + ], +) +async def test_update_item( + hass: HomeAssistant, + config_entry: MockConfigEntry, + dav_client: Mock, + calendar: Mock, + update_data: dict[str, Any], + expected_ics: list[str], + expected_state: str, + expected_item: dict[str, Any], +) -> None: + """Test updating an item on the list.""" + + item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") + calendar.search = MagicMock(return_value=[item]) + + await config_entry.async_setup(hass) + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + calendar.todo_by_uid = MagicMock(return_value=item) + + dav_client.put.return_value.status = 204 + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + { + "item": "Cheese", + **update_data, + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + assert dav_client.put.call_args + ics = dav_client.put.call_args.args[1] + for expected in expected_ics: + assert expected in ics + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == expected_state + + result = await hass.services.async_call( + TODO_DOMAIN, + "get_items", + {}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + return_response=True, + ) + assert result == {TEST_ENTITY: {"items": [expected_item]}} + + +async def test_update_item_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + dav_client: Mock, + calendar: Mock, +) -> None: + """Test failure when updating an item on the list.""" + + item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") + calendar.search = MagicMock(return_value=[item]) + + await config_entry.async_setup(hass) + + calendar.todo_by_uid = MagicMock(return_value=item) + dav_client.put.side_effect = DAVError() + + with pytest.raises(HomeAssistantError, match="CalDAV save error"): + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + { + "item": "Cheese", + "status": "completed", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("side_effect", "match"), + [(DAVError, "CalDAV lookup error"), (NotFoundError, "Could not find")], +) +async def test_update_item_lookup_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + dav_client: Mock, + calendar: Mock, + side_effect: Any, + match: str, +) -> None: + """Test failure when looking up an item to update.""" + + item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") + calendar.search = MagicMock(return_value=[item]) + + await config_entry.async_setup(hass) + + calendar.todo_by_uid.side_effect = side_effect + + with pytest.raises(HomeAssistantError, match=match): + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + { + "item": "Cheese", + "status": "completed", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("uids_to_delete", "expect_item1_delete_called", "expect_item2_delete_called"), + [ + ([], False, False), + (["Cheese"], True, False), + (["Wine"], False, True), + (["Wine", "Cheese"], True, True), + ], + ids=("none", "item1-only", "item2-only", "both-items"), +) +async def test_remove_item( + hass: HomeAssistant, + config_entry: MockConfigEntry, + dav_client: Mock, + calendar: Mock, + uids_to_delete: list[str], + expect_item1_delete_called: bool, + expect_item2_delete_called: bool, +) -> None: + """Test removing an item on the list.""" + + item1 = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") + item2 = Todo(dav_client, None, TODO_COMPLETED, calendar, "3") + calendar.search = MagicMock(return_value=[item1, item2]) + + await config_entry.async_setup(hass) + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + def lookup(uid: str) -> Mock: + assert uid == "2" or uid == "3" + if uid == "2": + return item1 + return item2 + + calendar.todo_by_uid = Mock(side_effect=lookup) + item1.delete = Mock() + item2.delete = Mock() + + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": uids_to_delete}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + assert item1.delete.called == expect_item1_delete_called + assert item2.delete.called == expect_item2_delete_called + + +@pytest.mark.parametrize( + ("todos", "side_effect", "match"), + [ + ([TODO_NEEDS_ACTION], DAVError, "CalDAV lookup error"), + ([TODO_NEEDS_ACTION], NotFoundError, "Could not find"), + ], +) +async def test_remove_item_lookup_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendar: Mock, + side_effect: Any, + match: str, +) -> None: + """Test failure while removing an item from the list.""" + + await config_entry.async_setup(hass) + + calendar.todo_by_uid.side_effect = side_effect + + with pytest.raises(HomeAssistantError, match=match): + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": "Cheese"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + +async def test_remove_item_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + dav_client: Mock, + calendar: Mock, +) -> None: + """Test removing an item on the list.""" + + item = Todo(dav_client, "2.ics", TODO_NEEDS_ACTION, calendar, "2") + calendar.search = MagicMock(return_value=[item]) + + await config_entry.async_setup(hass) + + def lookup(uid: str) -> Mock: + return item + + calendar.todo_by_uid = Mock(side_effect=lookup) + dav_client.delete.return_value.status = 500 + + with pytest.raises(HomeAssistantError, match="CalDAV delete error"): + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": "Cheese"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + +async def test_remove_item_not_found( + hass: HomeAssistant, + config_entry: MockConfigEntry, + dav_client: Mock, + calendar: Mock, +) -> None: + """Test removing an item on the list.""" + + item = Todo(dav_client, "2.ics", TODO_NEEDS_ACTION, calendar, "2") + calendar.search = MagicMock(return_value=[item]) + + await config_entry.async_setup(hass) + + def lookup(uid: str) -> Mock: + return item + + calendar.todo_by_uid.side_effect = NotFoundError() + + with pytest.raises(HomeAssistantError, match="Could not find"): + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": "Cheese"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + +async def test_subscribe( + hass: HomeAssistant, + config_entry: MockConfigEntry, + dav_client: Mock, + calendar: Mock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test subscription to item updates.""" + + item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") + calendar.search = MagicMock(return_value=[item]) + + await config_entry.async_setup(hass) + + # Subscribe and get the initial list + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "todo/item/subscribe", + "entity_id": TEST_ENTITY, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "Cheese" + assert items[0]["status"] == "needs_action" + assert items[0]["uid"] + + calendar.todo_by_uid = MagicMock(return_value=item) + dav_client.put.return_value.status = 204 + # Reflect update for state refresh after update + calendar.search.return_value = [ + Todo( + dav_client, None, TODO_NEEDS_ACTION.replace("Cheese", "Milk"), calendar, "2" + ) + ] + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + { + "item": "Cheese", + "rename": "Milk", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify update is published + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "Milk" + assert items[0]["status"] == "needs_action" + assert items[0]["uid"] diff --git a/tests/components/calendar/snapshots/test_init.ambr b/tests/components/calendar/snapshots/test_init.ambr index 7d48228193a..67e8839f7a5 100644 --- a/tests/components/calendar/snapshots/test_init.ambr +++ b/tests/components/calendar/snapshots/test_init.ambr @@ -1,11 +1,34 @@ # serializer version: 1 -# name: test_list_events_service_duration[calendar.calendar_1-00:15:00] +# name: test_list_events_service_duration[calendar.calendar_1-00:15:00-get_events] + dict({ + 'calendar.calendar_1': dict({ + 'events': list([ + ]), + }), + }) +# --- +# name: test_list_events_service_duration[calendar.calendar_1-00:15:00-list_events] dict({ 'events': list([ ]), }) # --- -# name: test_list_events_service_duration[calendar.calendar_1-01:00:00] +# name: test_list_events_service_duration[calendar.calendar_1-01:00:00-get_events] + dict({ + 'calendar.calendar_1': dict({ + 'events': list([ + dict({ + 'description': 'Future Description', + 'end': '2023-10-19T08:20:05-07:00', + 'location': 'Future Location', + 'start': '2023-10-19T07:20:05-07:00', + 'summary': 'Future Event', + }), + ]), + }), + }) +# --- +# name: test_list_events_service_duration[calendar.calendar_1-01:00:00-list_events] dict({ 'events': list([ dict({ @@ -18,7 +41,20 @@ ]), }) # --- -# name: test_list_events_service_duration[calendar.calendar_2-00:15:00] +# name: test_list_events_service_duration[calendar.calendar_2-00:15:00-get_events] + dict({ + 'calendar.calendar_2': dict({ + 'events': list([ + dict({ + 'end': '2023-10-19T07:20:05-07:00', + 'start': '2023-10-19T06:20:05-07:00', + 'summary': 'Current Event', + }), + ]), + }), + }) +# --- +# name: test_list_events_service_duration[calendar.calendar_2-00:15:00-list_events] dict({ 'events': list([ dict({ diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index ad83d039d73..25804287172 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -12,9 +12,14 @@ from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.bootstrap import async_setup_component -from homeassistant.components.calendar import DOMAIN, SERVICE_LIST_EVENTS +from homeassistant.components.calendar import ( + DOMAIN, + LEGACY_SERVICE_LIST_EVENTS, + SERVICE_GET_EVENTS, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.issue_registry import IssueRegistry import homeassistant.util.dt as dt_util from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -389,6 +394,41 @@ async def test_create_event_service_invalid_params( @freeze_time("2023-06-22 10:30:00+00:00") +@pytest.mark.parametrize( + ("service", "expected"), + [ + ( + LEGACY_SERVICE_LIST_EVENTS, + { + "events": [ + { + "start": "2023-06-22T05:00:00-06:00", + "end": "2023-06-22T06:00:00-06:00", + "summary": "Future Event", + "description": "Future Description", + "location": "Future Location", + } + ] + }, + ), + ( + SERVICE_GET_EVENTS, + { + "calendar.calendar_1": { + "events": [ + { + "start": "2023-06-22T05:00:00-06:00", + "end": "2023-06-22T06:00:00-06:00", + "summary": "Future Event", + "description": "Future Description", + "location": "Future Location", + } + ] + } + }, + ), + ], +) @pytest.mark.parametrize( ("start_time", "end_time"), [ @@ -402,6 +442,8 @@ async def test_list_events_service( set_time_zone: None, start_time: str, end_time: str, + service: str, + expected: dict[str, Any], ) -> None: """Test listing events from the service call using exlplicit start and end time. @@ -414,8 +456,9 @@ async def test_list_events_service( response = await hass.services.async_call( DOMAIN, - SERVICE_LIST_EVENTS, - { + service, + target={"entity_id": ["calendar.calendar_1"]}, + service_data={ "entity_id": "calendar.calendar_1", "start_date_time": start_time, "end_date_time": end_time, @@ -423,19 +466,16 @@ async def test_list_events_service( blocking=True, return_response=True, ) - assert response == { - "events": [ - { - "start": "2023-06-22T05:00:00-06:00", - "end": "2023-06-22T06:00:00-06:00", - "summary": "Future Event", - "description": "Future Description", - "location": "Future Location", - } - ] - } + assert response == expected +@pytest.mark.parametrize( + ("service"), + [ + (LEGACY_SERVICE_LIST_EVENTS), + SERVICE_GET_EVENTS, + ], +) @pytest.mark.parametrize( ("entity", "duration"), [ @@ -452,6 +492,7 @@ async def test_list_events_service_duration( hass: HomeAssistant, entity: str, duration: str, + service: str, snapshot: SnapshotAssertion, ) -> None: """Test listing events using a time duration.""" @@ -460,7 +501,7 @@ async def test_list_events_service_duration( response = await hass.services.async_call( DOMAIN, - SERVICE_LIST_EVENTS, + service, { "entity_id": entity, "duration": duration, @@ -479,7 +520,7 @@ async def test_list_events_positive_duration(hass: HomeAssistant) -> None: with pytest.raises(vol.Invalid, match="should be positive"): await hass.services.async_call( DOMAIN, - SERVICE_LIST_EVENTS, + SERVICE_GET_EVENTS, { "entity_id": "calendar.calendar_1", "duration": "-01:00:00", @@ -499,7 +540,7 @@ async def test_list_events_exclusive_fields(hass: HomeAssistant) -> None: with pytest.raises(vol.Invalid, match="at most one of"): await hass.services.async_call( DOMAIN, - SERVICE_LIST_EVENTS, + SERVICE_GET_EVENTS, { "entity_id": "calendar.calendar_1", "end_date_time": end, @@ -518,10 +559,47 @@ async def test_list_events_missing_fields(hass: HomeAssistant) -> None: with pytest.raises(vol.Invalid, match="at least one of"): await hass.services.async_call( DOMAIN, - SERVICE_LIST_EVENTS, + SERVICE_GET_EVENTS, { "entity_id": "calendar.calendar_1", }, blocking=True, return_response=True, ) + + +async def test_issue_deprecated_service_calendar_list_events( + hass: HomeAssistant, + issue_registry: IssueRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the issue is raised on deprecated service weather.get_forecast.""" + + await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) + await hass.async_block_till_done() + + _ = await hass.services.async_call( + DOMAIN, + LEGACY_SERVICE_LIST_EVENTS, + target={"entity_id": ["calendar.calendar_1"]}, + service_data={ + "entity_id": "calendar.calendar_1", + "duration": "01:00:00", + }, + blocking=True, + return_response=True, + ) + + issue = issue_registry.async_get_issue( + "calendar", "deprecated_service_calendar_list_events" + ) + assert issue + assert issue.issue_domain == "demo" + assert issue.issue_id == "deprecated_service_calendar_list_events" + assert issue.translation_key == "deprecated_service_calendar_list_events" + + assert ( + "Detected use of service 'calendar.list_events'. " + "This is deprecated and will stop working in Home Assistant 2024.6. " + "Use 'calendar.get_events' instead which supports multiple entities" + ) in caplog.text diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 2a91a375a13..8e49e00e498 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -367,7 +367,10 @@ async def test_websocket_update_preload_prefs( async def test_websocket_update_orientation_prefs( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_camera + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, + mock_camera, ) -> None: """Test updating camera preferences.""" await async_setup_component(hass, "homeassistant", {}) @@ -387,11 +390,10 @@ async def test_websocket_update_orientation_prefs( assert not response["success"] assert response["error"]["code"] == "update_failed" - registry = er.async_get(hass) - assert not registry.async_get("camera.demo_uniquecamera") + assert not entity_registry.async_get("camera.demo_uniquecamera") # Since we don't have a unique id, we need to create a registry entry - registry.async_get_or_create(DOMAIN, "demo", "uniquecamera") - registry.async_update_entity_options( + entity_registry.async_get_or_create(DOMAIN, "demo", "uniquecamera") + entity_registry.async_update_entity_options( "camera.demo_uniquecamera", DOMAIN, {}, @@ -408,7 +410,9 @@ async def test_websocket_update_orientation_prefs( response = await client.receive_json() assert response["success"] - er_camera_prefs = registry.async_get("camera.demo_uniquecamera").options[DOMAIN] + er_camera_prefs = entity_registry.async_get("camera.demo_uniquecamera").options[ + DOMAIN + ] assert er_camera_prefs[PREF_ORIENTATION] == camera.Orientation.ROTATE_180 assert response["result"][PREF_ORIENTATION] == er_camera_prefs[PREF_ORIENTATION] # Check that the preference was saved diff --git a/tests/components/cast/test_config_flow.py b/tests/components/cast/test_config_flow.py index 2d688489d39..9b5c2d56d4c 100644 --- a/tests/components/cast/test_config_flow.py +++ b/tests/components/cast/test_config_flow.py @@ -19,7 +19,7 @@ async def test_creating_entry_sets_up_media_player(hass: HomeAssistant) -> None: ) as mock_setup, patch( "pychromecast.discovery.discover_chromecasts", return_value=(True, None) ), patch( - "pychromecast.discovery.stop_discovery" + "pychromecast.discovery.stop_discovery", ): result = await hass.config_entries.flow.async_init( cast.DOMAIN, context={"source": config_entries.SOURCE_USER} diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 3d9feb3e43c..2af5e67f845 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -552,6 +552,7 @@ async def test_auto_cast_chromecasts(hass: HomeAssistant) -> None: async def test_discover_dynamic_group( hass: HomeAssistant, + entity_registry: er.EntityRegistry, get_multizone_status_mock, get_chromecast_mock, caplog: pytest.LogCaptureFixture, @@ -562,8 +563,6 @@ async def test_discover_dynamic_group( zconf_1 = get_fake_zconf(host="host_1", port=23456) zconf_2 = get_fake_zconf(host="host_2", port=34567) - reg = er.async_get(hass) - # Fake dynamic group info tmp1 = MagicMock() tmp1.uuid = FakeUUID @@ -606,7 +605,9 @@ async def test_discover_dynamic_group( get_chromecast_mock.assert_called() get_chromecast_mock.reset_mock() assert add_dev1.call_count == 0 - assert reg.async_get_entity_id("media_player", "cast", cast_1.uuid) is None + assert ( + entity_registry.async_get_entity_id("media_player", "cast", cast_1.uuid) is None + ) # Discover other dynamic group cast service with patch( @@ -632,7 +633,9 @@ async def test_discover_dynamic_group( get_chromecast_mock.assert_called() get_chromecast_mock.reset_mock() assert add_dev1.call_count == 0 - assert reg.async_get_entity_id("media_player", "cast", cast_2.uuid) is None + assert ( + entity_registry.async_get_entity_id("media_player", "cast", cast_2.uuid) is None + ) # Get update for cast service with patch( @@ -655,7 +658,9 @@ async def test_discover_dynamic_group( assert len(tasks) == 0 get_chromecast_mock.assert_not_called() assert add_dev1.call_count == 0 - assert reg.async_get_entity_id("media_player", "cast", cast_1.uuid) is None + assert ( + entity_registry.async_get_entity_id("media_player", "cast", cast_1.uuid) is None + ) # Remove cast service assert "Disconnecting from chromecast" not in caplog.text @@ -765,14 +770,17 @@ async def test_entity_availability(hass: HomeAssistant) -> None: @pytest.mark.parametrize(("port", "entry_type"), ((8009, None), (12345, None))) async def test_device_registry( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, port, entry_type + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + port, + entry_type, ) -> None: """Test device registry integration.""" assert await async_setup_component(hass, "config", {}) entity_id = "media_player.speaker" - reg = er.async_get(hass) - dev_reg = dr.async_get(hass) info = get_fake_chromecast_info(port=port) @@ -790,9 +798,11 @@ async def test_device_registry( assert state is not None assert state.name == "Speaker" assert state.state == "off" - assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) - entity_entry = reg.async_get(entity_id) - device_entry = dev_reg.async_get(entity_entry.device_id) + assert entity_id == entity_registry.async_get_entity_id( + "media_player", "cast", str(info.uuid) + ) + entity_entry = entity_registry.async_get(entity_id) + device_entry = device_registry.async_get(entity_entry.device_id) assert entity_entry.device_id == device_entry.id assert device_entry.entry_type == entry_type @@ -815,14 +825,15 @@ async def test_device_registry( await hass.async_block_till_done() chromecast.disconnect.assert_called_once() - assert reg.async_get(entity_id) is None - assert dev_reg.async_get(entity_entry.device_id) is None + assert entity_registry.async_get(entity_id) is None + assert device_registry.async_get(entity_entry.device_id) is None -async def test_entity_cast_status(hass: HomeAssistant) -> None: +async def test_entity_cast_status( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test handling of cast status.""" entity_id = "media_player.speaker" - reg = er.async_get(hass) info = get_fake_chromecast_info() @@ -839,7 +850,9 @@ async def test_entity_cast_status(hass: HomeAssistant) -> None: assert state is not None assert state.name == "Speaker" assert state.state == "off" - assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) + assert entity_id == entity_registry.async_get_entity_id( + "media_player", "cast", str(info.uuid) + ) # No media status, pause, play, stop not supported assert state.attributes.get("supported_features") == ( @@ -1088,10 +1101,11 @@ async def test_entity_browse_media_audio_only( assert expected_child_2 in response["result"]["children"] -async def test_entity_play_media(hass: HomeAssistant, quick_play_mock) -> None: +async def test_entity_play_media( + hass: HomeAssistant, entity_registry: er.EntityRegistry, quick_play_mock +) -> None: """Test playing media.""" entity_id = "media_player.speaker" - reg = er.async_get(hass) info = get_fake_chromecast_info() @@ -1107,7 +1121,9 @@ async def test_entity_play_media(hass: HomeAssistant, quick_play_mock) -> None: assert state is not None assert state.name == "Speaker" assert state.state == "off" - assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) + assert entity_id == entity_registry.async_get_entity_id( + "media_player", "cast", str(info.uuid) + ) # Play_media await hass.services.async_call( @@ -1134,10 +1150,11 @@ async def test_entity_play_media(hass: HomeAssistant, quick_play_mock) -> None: ) -async def test_entity_play_media_cast(hass: HomeAssistant, quick_play_mock) -> None: +async def test_entity_play_media_cast( + hass: HomeAssistant, entity_registry: er.EntityRegistry, quick_play_mock +) -> None: """Test playing media with cast special features.""" entity_id = "media_player.speaker" - reg = er.async_get(hass) info = get_fake_chromecast_info() @@ -1153,7 +1170,9 @@ async def test_entity_play_media_cast(hass: HomeAssistant, quick_play_mock) -> N assert state is not None assert state.name == "Speaker" assert state.state == "off" - assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) + assert entity_id == entity_registry.async_get_entity_id( + "media_player", "cast", str(info.uuid) + ) # Play_media - cast with app ID await common.async_play_media(hass, "cast", '{"app_id": "abc123"}', entity_id) @@ -1177,11 +1196,13 @@ async def test_entity_play_media_cast(hass: HomeAssistant, quick_play_mock) -> N async def test_entity_play_media_cast_invalid( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, quick_play_mock + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, + quick_play_mock, ) -> None: """Test playing media.""" entity_id = "media_player.speaker" - reg = er.async_get(hass) info = get_fake_chromecast_info() @@ -1197,7 +1218,9 @@ async def test_entity_play_media_cast_invalid( assert state is not None assert state.name == "Speaker" assert state.state == "off" - assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) + assert entity_id == entity_registry.async_get_entity_id( + "media_player", "cast", str(info.uuid) + ) # play_media - media_type cast with invalid JSON with pytest.raises(json.decoder.JSONDecodeError): @@ -1345,11 +1368,13 @@ async def test_entity_play_media_playlist( ], ) async def test_entity_media_content_type( - hass: HomeAssistant, cast_type, default_content_type + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + cast_type, + default_content_type, ) -> None: """Test various content types.""" entity_id = "media_player.speaker" - reg = er.async_get(hass) info = get_fake_chromecast_info() @@ -1366,7 +1391,9 @@ async def test_entity_media_content_type( assert state is not None assert state.name == "Speaker" assert state.state == "off" - assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) + assert entity_id == entity_registry.async_get_entity_id( + "media_player", "cast", str(info.uuid) + ) media_status = MagicMock(images=None) media_status.media_is_movie = False @@ -1398,10 +1425,11 @@ async def test_entity_media_content_type( assert state.attributes.get("media_content_type") == "movie" -async def test_entity_control(hass: HomeAssistant, quick_play_mock) -> None: +async def test_entity_control( + hass: HomeAssistant, entity_registry: er.EntityRegistry, quick_play_mock +) -> None: """Test various device and media controls.""" entity_id = "media_player.speaker" - reg = er.async_get(hass) info = get_fake_chromecast_info() @@ -1427,7 +1455,9 @@ async def test_entity_control(hass: HomeAssistant, quick_play_mock) -> None: assert state is not None assert state.name == "Speaker" assert state.state == "playing" - assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) + assert entity_id == entity_registry.async_get_entity_id( + "media_player", "cast", str(info.uuid) + ) assert state.attributes.get("supported_features") == ( MediaPlayerEntityFeature.PAUSE @@ -1527,10 +1557,11 @@ async def test_entity_control(hass: HomeAssistant, quick_play_mock) -> None: ("app_id", "state_no_media"), [(pychromecast.APP_YOUTUBE, "idle"), ("Netflix", "playing")], ) -async def test_entity_media_states(hass: HomeAssistant, app_id, state_no_media) -> None: +async def test_entity_media_states( + hass: HomeAssistant, entity_registry: er.EntityRegistry, app_id, state_no_media +) -> None: """Test various entity media states.""" entity_id = "media_player.speaker" - reg = er.async_get(hass) info = get_fake_chromecast_info() @@ -1546,7 +1577,9 @@ async def test_entity_media_states(hass: HomeAssistant, app_id, state_no_media) assert state is not None assert state.name == "Speaker" assert state.state == "off" - assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) + assert entity_id == entity_registry.async_get_entity_id( + "media_player", "cast", str(info.uuid) + ) # App id updated, but no media status chromecast.app_id = app_id @@ -1606,10 +1639,11 @@ async def test_entity_media_states(hass: HomeAssistant, app_id, state_no_media) assert state.state == "unknown" -async def test_entity_media_states_lovelace_app(hass: HomeAssistant) -> None: +async def test_entity_media_states_lovelace_app( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test various entity media states when the lovelace app is active.""" entity_id = "media_player.speaker" - reg = er.async_get(hass) info = get_fake_chromecast_info() @@ -1625,7 +1659,9 @@ async def test_entity_media_states_lovelace_app(hass: HomeAssistant) -> None: assert state is not None assert state.name == "Speaker" assert state.state == "off" - assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) + assert entity_id == entity_registry.async_get_entity_id( + "media_player", "cast", str(info.uuid) + ) chromecast.app_id = CAST_APP_ID_HOMEASSISTANT_LOVELACE cast_status = MagicMock() @@ -1677,10 +1713,11 @@ async def test_entity_media_states_lovelace_app(hass: HomeAssistant) -> None: assert state.state == "unknown" -async def test_group_media_states(hass: HomeAssistant, mz_mock) -> None: +async def test_group_media_states( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mz_mock +) -> None: """Test media states are read from group if entity has no state.""" entity_id = "media_player.speaker" - reg = er.async_get(hass) info = get_fake_chromecast_info() @@ -1698,7 +1735,9 @@ async def test_group_media_states(hass: HomeAssistant, mz_mock) -> None: assert state is not None assert state.name == "Speaker" assert state.state == "off" - assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) + assert entity_id == entity_registry.async_get_entity_id( + "media_player", "cast", str(info.uuid) + ) group_media_status = MagicMock(images=None) player_media_status = MagicMock(images=None) @@ -1734,13 +1773,14 @@ async def test_group_media_states(hass: HomeAssistant, mz_mock) -> None: assert state.state == "playing" -async def test_group_media_states_early(hass: HomeAssistant, mz_mock) -> None: +async def test_group_media_states_early( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mz_mock +) -> None: """Test media states are read from group if entity has no state. This tests case asserts group state is polled when the player is created. """ entity_id = "media_player.speaker" - reg = er.async_get(hass) info = get_fake_chromecast_info() @@ -1756,7 +1796,9 @@ async def test_group_media_states_early(hass: HomeAssistant, mz_mock) -> None: assert state is not None assert state.name == "Speaker" assert state.state == "unavailable" - assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) + assert entity_id == entity_registry.async_get_entity_id( + "media_player", "cast", str(info.uuid) + ) # Check group state is polled when player is first created connection_status = MagicMock() @@ -1788,11 +1830,10 @@ async def test_group_media_states_early(hass: HomeAssistant, mz_mock) -> None: async def test_group_media_control( - hass: HomeAssistant, mz_mock, quick_play_mock + hass: HomeAssistant, entity_registry: er.EntityRegistry, mz_mock, quick_play_mock ) -> None: """Test media controls are handled by group if entity has no state.""" entity_id = "media_player.speaker" - reg = er.async_get(hass) info = get_fake_chromecast_info() @@ -1811,7 +1852,9 @@ async def test_group_media_control( assert state is not None assert state.name == "Speaker" assert state.state == "off" - assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) + assert entity_id == entity_registry.async_get_entity_id( + "media_player", "cast", str(info.uuid) + ) group_media_status = MagicMock(images=None) player_media_status = MagicMock(images=None) diff --git a/tests/components/climate/test_intent.py b/tests/components/climate/test_intent.py new file mode 100644 index 00000000000..eaf7029d303 --- /dev/null +++ b/tests/components/climate/test_intent.py @@ -0,0 +1,221 @@ +"""Test climate intents.""" +from collections.abc import Generator +from unittest.mock import patch + +import pytest + +from homeassistant.components.climate import ( + DOMAIN, + ClimateEntity, + HVACMode, + intent as climate_intent, +) +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import Platform, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers import area_registry as ar, entity_registry as er, intent +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) + +TEST_DOMAIN = "test" + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture(autouse=True) +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +@pytest.fixture(autouse=True) +def mock_setup_integration(hass: HomeAssistant) -> None: + """Fixture to set up a mock integration.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, + config_entry: ConfigEntry, + ) -> bool: + await hass.config_entries.async_unload_platforms(config_entry, [Platform.TODO]) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + + +async def create_mock_platform( + hass: HomeAssistant, + entities: list[ClimateEntity], +) -> MockConfigEntry: + """Create a todo platform with the specified entities.""" + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test event platform via config entry.""" + async_add_entities(entities) + + 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() + + return config_entry + + +class MockClimateEntity(ClimateEntity): + """Mock Climate device to use in tests.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_mode = HVACMode.OFF + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + + +async def test_get_temperature( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test HassClimateGetTemperature intent.""" + await climate_intent.async_setup_intents(hass) + + climate_1 = MockClimateEntity() + climate_1._attr_name = "Climate 1" + climate_1._attr_unique_id = "1234" + climate_1._attr_current_temperature = 10.0 + entity_registry.async_get_or_create( + DOMAIN, "test", "1234", suggested_object_id="climate_1" + ) + + climate_2 = MockClimateEntity() + climate_2._attr_name = "Climate 2" + climate_2._attr_unique_id = "5678" + climate_2._attr_current_temperature = 22.0 + entity_registry.async_get_or_create( + DOMAIN, "test", "5678", suggested_object_id="climate_2" + ) + + await create_mock_platform(hass, [climate_1, climate_2]) + + # Add climate entities to different areas: + # climate_1 => living room + # climate_2 => bedroom + living_room_area = area_registry.async_create(name="Living Room") + bedroom_area = area_registry.async_create(name="Bedroom") + + entity_registry.async_update_entity( + climate_1.entity_id, area_id=living_room_area.id + ) + entity_registry.async_update_entity(climate_2.entity_id, area_id=bedroom_area.id) + + # First climate entity will be selected (no area) + response = await intent.async_handle( + hass, "test", climate_intent.INTENT_GET_TEMPERATURE, {} + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_1.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 10.0 + + # Select by area instead (climate_2) + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"area": {"value": "Bedroom"}}, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 22.0 + + +async def test_get_temperature_no_entities( + hass: HomeAssistant, +) -> None: + """Test HassClimateGetTemperature intent with no climate entities.""" + await climate_intent.async_setup_intents(hass) + + await create_mock_platform(hass, []) + + with pytest.raises(intent.IntentHandleError): + await intent.async_handle( + hass, "test", climate_intent.INTENT_GET_TEMPERATURE, {} + ) + + +async def test_get_temperature_no_state( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test HassClimateGetTemperature intent when states are missing.""" + await climate_intent.async_setup_intents(hass) + + climate_1 = MockClimateEntity() + climate_1._attr_name = "Climate 1" + climate_1._attr_unique_id = "1234" + entity_registry.async_get_or_create( + DOMAIN, "test", "1234", suggested_object_id="climate_1" + ) + + await create_mock_platform(hass, [climate_1]) + + living_room_area = area_registry.async_create(name="Living Room") + entity_registry.async_update_entity( + climate_1.entity_id, area_id=living_room_area.id + ) + + with patch("homeassistant.core.StateMachine.get", return_value=None), pytest.raises( + intent.IntentHandleError + ): + await intent.async_handle( + hass, "test", climate_intent.INTENT_GET_TEMPERATURE, {} + ) + + with patch( + "homeassistant.core.StateMachine.async_all", return_value=[] + ), pytest.raises(intent.IntentHandleError): + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"area": {"value": "Living Room"}}, + ) diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index e205ba5f6e8..ff718262b10 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -1,6 +1,6 @@ """Test the cloud.iot module.""" from datetime import timedelta -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch import aiohttp from aiohttp import web @@ -248,10 +248,12 @@ async def test_webhook_msg( async def test_google_config_expose_entity( - hass: HomeAssistant, mock_cloud_setup, mock_cloud_login + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_cloud_setup, + mock_cloud_login, ) -> None: """Test Google config exposing entity method uses latest config.""" - entity_registry = er.async_get(hass) # Enable exposing new entities to Google exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] @@ -274,10 +276,12 @@ async def test_google_config_expose_entity( async def test_google_config_should_2fa( - hass: HomeAssistant, mock_cloud_setup, mock_cloud_login + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_cloud_setup, + mock_cloud_login, ) -> None: """Test Google config disabling 2FA method uses latest config.""" - entity_registry = er.async_get(hass) # Register a light entity entity_entry = entity_registry.async_get_or_create( @@ -357,7 +361,10 @@ async def test_system_msg(hass: HomeAssistant) -> None: async def test_cloud_connection_info(hass: HomeAssistant) -> None: """Test connection info msg.""" - with patch("hass_nabucasa.Cloud.initialize"): + with patch("hass_nabucasa.Cloud.initialize"), patch( + "uuid.UUID.hex", new_callable=PropertyMock + ) as hexmock: + hexmock.return_value = "12345678901234567890" setup = await async_setup_component(hass, "cloud", {"cloud": {}}) assert setup cloud = hass.data["cloud"] @@ -372,4 +379,5 @@ async def test_cloud_connection_info(hass: HomeAssistant) -> None: "alias": None, }, "version": HA_VERSION, + "instance_id": "12345678901234567890", } diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index fe60ca971a1..39bf60570f2 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -120,11 +120,10 @@ async def test_sync_entities(mock_conf, hass: HomeAssistant, cloud_prefs) -> Non async def test_google_update_expose_trigger_sync( - hass: HomeAssistant, cloud_prefs + hass: HomeAssistant, entity_registry: er.EntityRegistry, cloud_prefs ) -> None: """Test Google config responds to updating exposed entities.""" assert await async_setup_component(hass, "homeassistant", {}) - entity_registry = er.async_get(hass) # Enable exposing new entities to Google expose_new(hass, True) @@ -176,10 +175,12 @@ async def test_google_update_expose_trigger_sync( async def test_google_entity_registry_sync( - hass: HomeAssistant, mock_cloud_login, cloud_prefs + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_cloud_login, + cloud_prefs, ) -> None: """Test Google config responds to entity registry.""" - entity_registry = er.async_get(hass) # Enable exposing new entities to Google expose_new(hass, True) @@ -246,19 +247,25 @@ async def test_google_entity_registry_sync( async def test_google_device_registry_sync( - hass: HomeAssistant, mock_cloud_login, cloud_prefs + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_cloud_login, + cloud_prefs, ) -> None: """Test Google config responds to device registry.""" config = CloudGoogleConfig( hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"] ) - ent_reg = er.async_get(hass) # Enable exposing new entities to Google expose_new(hass, True) - entity_entry = ent_reg.async_get_or_create("light", "hue", "1234", device_id="1234") - entity_entry = ent_reg.async_update_entity(entity_entry.entity_id, area_id="ABCD") + entity_entry = entity_registry.async_get_or_create( + "light", "hue", "1234", device_id="1234" + ) + entity_entry = entity_registry.async_update_entity( + entity_entry.entity_id, area_id="ABCD" + ) with patch.object(config, "async_sync_entities_all"): await config.async_initialize() @@ -293,7 +300,7 @@ async def test_google_device_registry_sync( assert len(mock_sync.mock_calls) == 0 - ent_reg.async_update_entity(entity_entry.entity_id, area_id=None) + entity_registry.async_update_entity(entity_entry.entity_id, area_id=None) # Device registry updated with relevant changes # but entity has area ID so not impacted diff --git a/tests/components/cloud/test_system_health.py b/tests/components/cloud/test_system_health.py index 79e45e9ba26..c540394b937 100644 --- a/tests/components/cloud/test_system_health.py +++ b/tests/components/cloud/test_system_health.py @@ -46,6 +46,7 @@ async def test_cloud_system_health( remote_enabled=True, alexa_enabled=True, google_enabled=False, + instance_id="12345678901234567890", ), ), ) @@ -70,4 +71,5 @@ async def test_cloud_system_health( "can_reach_cert_server": "ok", "can_reach_cloud_auth": {"type": "failed", "error": "unreachable"}, "can_reach_cloud": "ok", + "instance_id": "12345678901234567890", } diff --git a/tests/components/cloudflare/__init__.py b/tests/components/cloudflare/__init__.py index f2eaccab470..8ba8b23b65f 100644 --- a/tests/components/cloudflare/__init__.py +++ b/tests/components/cloudflare/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, patch -from pycfdns import CFRecord +import pycfdns from homeassistant.components.cloudflare.const import CONF_RECORDS, DOMAIN from homeassistant.const import CONF_API_TOKEN, CONF_ZONE @@ -26,9 +26,8 @@ USER_INPUT_ZONE = {CONF_ZONE: "mock.com"} USER_INPUT_RECORDS = {CONF_RECORDS: ["ha.mock.com", "homeassistant.mock.com"]} -MOCK_ZONE = "mock.com" -MOCK_ZONE_ID = "mock-zone-id" -MOCK_ZONE_RECORDS = [ +MOCK_ZONE: pycfdns.ZoneModel = {"name": "mock.com", "id": "mock-zone-id"} +MOCK_ZONE_RECORDS: list[pycfdns.RecordModel] = [ { "id": "zone-record-id", "type": "A", @@ -77,21 +76,12 @@ async def init_integration( return entry -def _get_mock_cfupdate( - zone: str = MOCK_ZONE, - zone_id: str = MOCK_ZONE_ID, - records: list = MOCK_ZONE_RECORDS, -): - client = AsyncMock() +def _get_mock_client(zone: str = MOCK_ZONE, records: list = MOCK_ZONE_RECORDS): + client: pycfdns.Client = AsyncMock() - zone_records = [record["name"] for record in records] - cf_records = [CFRecord(record) for record in records] - - client.get_zones = AsyncMock(return_value=[zone]) - client.get_zone_records = AsyncMock(return_value=zone_records) - client.get_record_info = AsyncMock(return_value=cf_records) - client.get_zone_id = AsyncMock(return_value=zone_id) - client.update_records = AsyncMock(return_value=None) + client.list_zones = AsyncMock(return_value=[zone]) + client.list_dns_records = AsyncMock(return_value=records) + client.update_dns_record = AsyncMock(return_value=None) return client diff --git a/tests/components/cloudflare/conftest.py b/tests/components/cloudflare/conftest.py index 0d9ac040c8e..de0e1a85b77 100644 --- a/tests/components/cloudflare/conftest.py +++ b/tests/components/cloudflare/conftest.py @@ -3,15 +3,15 @@ from unittest.mock import patch import pytest -from . import _get_mock_cfupdate +from . import _get_mock_client @pytest.fixture def cfupdate(hass): """Mock the CloudflareUpdater for easier testing.""" - mock_cfupdate = _get_mock_cfupdate() + mock_cfupdate = _get_mock_client() with patch( - "homeassistant.components.cloudflare.CloudflareUpdater", + "homeassistant.components.cloudflare.pycfdns.Client", return_value=mock_cfupdate, ) as mock_api: yield mock_api @@ -20,9 +20,9 @@ def cfupdate(hass): @pytest.fixture def cfupdate_flow(hass): """Mock the CloudflareUpdater for easier config flow testing.""" - mock_cfupdate = _get_mock_cfupdate() + mock_cfupdate = _get_mock_client() with patch( - "homeassistant.components.cloudflare.config_flow.CloudflareUpdater", + "homeassistant.components.cloudflare.pycfdns.Client", return_value=mock_cfupdate, ) as mock_api: yield mock_api diff --git a/tests/components/cloudflare/test_config_flow.py b/tests/components/cloudflare/test_config_flow.py index c0373866580..21ee364eca3 100644 --- a/tests/components/cloudflare/test_config_flow.py +++ b/tests/components/cloudflare/test_config_flow.py @@ -1,9 +1,5 @@ """Test the Cloudflare config flow.""" -from pycfdns.exceptions import ( - CloudflareAuthenticationException, - CloudflareConnectionException, - CloudflareZoneException, -) +import pycfdns from homeassistant.components.cloudflare.const import CONF_RECORDS, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER @@ -81,7 +77,7 @@ async def test_user_form_cannot_connect(hass: HomeAssistant, cfupdate_flow) -> N DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) - instance.get_zones.side_effect = CloudflareConnectionException() + instance.list_zones.side_effect = pycfdns.ComunicationException() result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, @@ -99,7 +95,7 @@ async def test_user_form_invalid_auth(hass: HomeAssistant, cfupdate_flow) -> Non DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) - instance.get_zones.side_effect = CloudflareAuthenticationException() + instance.list_zones.side_effect = pycfdns.AuthenticationException() result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, @@ -109,24 +105,6 @@ async def test_user_form_invalid_auth(hass: HomeAssistant, cfupdate_flow) -> Non assert result["errors"] == {"base": "invalid_auth"} -async def test_user_form_invalid_zone(hass: HomeAssistant, cfupdate_flow) -> None: - """Test we handle invalid zone error.""" - instance = cfupdate_flow.return_value - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: SOURCE_USER} - ) - - instance.get_zones.side_effect = CloudflareZoneException() - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT, - ) - - assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": "invalid_zone"} - - async def test_user_form_unexpected_exception( hass: HomeAssistant, cfupdate_flow ) -> None: @@ -137,7 +115,7 @@ async def test_user_form_unexpected_exception( DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) - instance.get_zones.side_effect = Exception() + instance.list_zones.side_effect = Exception() result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, diff --git a/tests/components/cloudflare/test_helpers.py b/tests/components/cloudflare/test_helpers.py new file mode 100644 index 00000000000..74bf8420f8a --- /dev/null +++ b/tests/components/cloudflare/test_helpers.py @@ -0,0 +1,13 @@ +"""Test Cloudflare integration helpers.""" +from homeassistant.components.cloudflare.helpers import get_zone_id + + +def test_get_zone_id(): + """Test get_zone_id.""" + zones = [ + {"id": "1", "name": "example.com"}, + {"id": "2", "name": "example.org"}, + ] + assert get_zone_id("example.com", zones) == "1" + assert get_zone_id("example.org", zones) == "2" + assert get_zone_id("example.net", zones) is None diff --git a/tests/components/cloudflare/test_init.py b/tests/components/cloudflare/test_init.py index 9d46a428042..d1c9cb3c352 100644 --- a/tests/components/cloudflare/test_init.py +++ b/tests/components/cloudflare/test_init.py @@ -1,22 +1,25 @@ """Test the Cloudflare integration.""" +from datetime import timedelta from unittest.mock import patch -from pycfdns.exceptions import ( - CloudflareAuthenticationException, - CloudflareConnectionException, - CloudflareZoneException, -) +import pycfdns import pytest -from homeassistant.components.cloudflare.const import DOMAIN, SERVICE_UPDATE_RECORDS +from homeassistant.components.cloudflare.const import ( + CONF_RECORDS, + DEFAULT_UPDATE_INTERVAL, + DOMAIN, + SERVICE_UPDATE_RECORDS, +) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +import homeassistant.util.dt as dt_util from homeassistant.util.location import LocationInfo from . import ENTRY_CONFIG, init_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_unload_entry(hass: HomeAssistant, cfupdate) -> None: @@ -35,10 +38,7 @@ async def test_unload_entry(hass: HomeAssistant, cfupdate) -> None: @pytest.mark.parametrize( "side_effect", - ( - CloudflareConnectionException(), - CloudflareZoneException(), - ), + (pycfdns.ComunicationException(),), ) async def test_async_setup_raises_entry_not_ready( hass: HomeAssistant, cfupdate, side_effect @@ -49,7 +49,7 @@ async def test_async_setup_raises_entry_not_ready( entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_CONFIG) entry.add_to_hass(hass) - instance.get_zone_id.side_effect = side_effect + instance.list_zones.side_effect = side_effect await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_RETRY @@ -64,7 +64,7 @@ async def test_async_setup_raises_entry_auth_failed( entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_CONFIG) entry.add_to_hass(hass) - instance.get_zone_id.side_effect = CloudflareAuthenticationException() + instance.list_zones.side_effect = pycfdns.AuthenticationException() await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_ERROR @@ -81,7 +81,7 @@ async def test_async_setup_raises_entry_auth_failed( assert flow["context"]["entry_id"] == entry.entry_id -async def test_integration_services(hass: HomeAssistant, cfupdate) -> None: +async def test_integration_services(hass: HomeAssistant, cfupdate, caplog) -> None: """Test integration services.""" instance = cfupdate.return_value @@ -112,7 +112,8 @@ async def test_integration_services(hass: HomeAssistant, cfupdate) -> None: ) await hass.async_block_till_done() - instance.update_records.assert_called_once() + assert len(instance.update_dns_record.mock_calls) == 2 + assert "All target records are up to date" not in caplog.text async def test_integration_services_with_issue(hass: HomeAssistant, cfupdate) -> None: @@ -134,4 +135,92 @@ async def test_integration_services_with_issue(hass: HomeAssistant, cfupdate) -> ) await hass.async_block_till_done() - instance.update_records.assert_not_called() + instance.update_dns_record.assert_not_called() + + +async def test_integration_services_with_nonexisting_record( + hass: HomeAssistant, cfupdate, caplog +) -> None: + """Test integration services.""" + instance = cfupdate.return_value + + entry = await init_integration( + hass, data={**ENTRY_CONFIG, CONF_RECORDS: ["nonexisting.example.com"]} + ) + assert entry.state is ConfigEntryState.LOADED + + with patch( + "homeassistant.components.cloudflare.async_detect_location_info", + return_value=LocationInfo( + "0.0.0.0", + "US", + "USD", + "CA", + "California", + "San Diego", + "92122", + "America/Los_Angeles", + 32.8594, + -117.2073, + True, + ), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_RECORDS, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + instance.update_dns_record.assert_not_called() + assert "All target records are up to date" in caplog.text + + +async def test_integration_update_interval( + hass: HomeAssistant, + cfupdate, + caplog, +) -> None: + """Test integration update interval.""" + instance = cfupdate.return_value + + entry = await init_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + with patch( + "homeassistant.components.cloudflare.async_detect_location_info", + return_value=LocationInfo( + "0.0.0.0", + "US", + "USD", + "CA", + "California", + "San Diego", + "92122", + "America/Los_Angeles", + 32.8594, + -117.2073, + True, + ), + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(minutes=DEFAULT_UPDATE_INTERVAL) + ) + await hass.async_block_till_done() + assert len(instance.update_dns_record.mock_calls) == 2 + assert "All target records are up to date" not in caplog.text + + instance.list_dns_records.side_effect = pycfdns.AuthenticationException() + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(minutes=DEFAULT_UPDATE_INTERVAL) + ) + await hass.async_block_till_done() + assert len(instance.update_dns_record.mock_calls) == 2 + + instance.list_dns_records.side_effect = pycfdns.ComunicationException() + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(minutes=DEFAULT_UPDATE_INTERVAL) + ) + await hass.async_block_till_done() + assert len(instance.update_dns_record.mock_calls) == 2 diff --git a/tests/components/co2signal/__init__.py b/tests/components/co2signal/__init__.py index 1f3d6a83c05..65764d75fe4 100644 --- a/tests/components/co2signal/__init__.py +++ b/tests/components/co2signal/__init__.py @@ -1,11 +1,18 @@ """Tests for the CO2 Signal integration.""" +from aioelectricitymaps.models import ( + CarbonIntensityData, + CarbonIntensityResponse, + CarbonIntensityUnit, +) -VALID_PAYLOAD = { - "status": "ok", - "countryCode": "FR", - "data": { - "carbonIntensity": 45.98623190095805, - "fossilFuelPercentage": 5.461182741937103, - }, - "units": {"carbonIntensity": "gCO2eq/kWh"}, -} +VALID_RESPONSE = CarbonIntensityResponse( + status="ok", + country_code="FR", + data=CarbonIntensityData( + carbon_intensity=45.98623190095805, + fossil_fuel_percentage=5.461182741937103, + ), + units=CarbonIntensityUnit( + carbon_intensity="gCO2eq/kWh", + ), +) diff --git a/tests/components/co2signal/conftest.py b/tests/components/co2signal/conftest.py new file mode 100644 index 00000000000..8eb0116bc88 --- /dev/null +++ b/tests/components/co2signal/conftest.py @@ -0,0 +1,52 @@ +"""Fixtures for Electricity maps integration tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.co2signal import DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.co2signal import VALID_RESPONSE + + +@pytest.fixture(name="electricity_maps") +def mock_electricity_maps() -> Generator[None, MagicMock, None]: + """Mock the ElectricityMaps client.""" + + with patch( + "homeassistant.components.co2signal.ElectricityMaps", + autospec=True, + ) as electricity_maps, patch( + "homeassistant.components.co2signal.config_flow.ElectricityMaps", + new=electricity_maps, + ): + client = electricity_maps.return_value + client.latest_carbon_intensity_by_coordinates.return_value = VALID_RESPONSE + client.latest_carbon_intensity_by_country_code.return_value = VALID_RESPONSE + + yield client + + +@pytest.fixture(name="config_entry") +async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a MockConfigEntry for testing.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "api_key", "location": ""}, + entry_id="904a74160aa6f335526706bee85dfb83", + ) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry, electricity_maps: AsyncMock +) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() diff --git a/tests/components/co2signal/snapshots/test_diagnostics.ambr b/tests/components/co2signal/snapshots/test_diagnostics.ambr index ffb35edfbbb..53a0f000f28 100644 --- a/tests/components/co2signal/snapshots/test_diagnostics.ambr +++ b/tests/components/co2signal/snapshots/test_diagnostics.ambr @@ -19,14 +19,14 @@ 'version': 1, }), 'data': dict({ - 'countryCode': 'FR', + 'country_code': 'FR', 'data': dict({ - 'carbonIntensity': 45.98623190095805, - 'fossilFuelPercentage': 5.461182741937103, + 'carbon_intensity': 45.98623190095805, + 'fossil_fuel_percentage': 5.461182741937103, }), 'status': 'ok', 'units': dict({ - 'carbonIntensity': 'gCO2eq/kWh', + 'carbon_intensity': 'gCO2eq/kWh', }), }), }) diff --git a/tests/components/co2signal/snapshots/test_sensor.ambr b/tests/components/co2signal/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..eb4364ed0d6 --- /dev/null +++ b/tests/components/co2signal/snapshots/test_sensor.ambr @@ -0,0 +1,101 @@ +# serializer version: 1 +# name: test_sensor[sensor.electricity_maps_co2_intensity] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.electricity_maps_co2_intensity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:molecule-co2', + 'original_name': 'CO2 intensity', + 'platform': 'co2signal', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'carbon_intensity', + 'unique_id': '904a74160aa6f335526706bee85dfb83_co2intensity', + 'unit_of_measurement': 'gCO2eq/kWh', + }) +# --- +# name: test_sensor[sensor.electricity_maps_co2_intensity].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Electricity Maps', + 'country_code': 'FR', + 'friendly_name': 'Electricity Maps CO2 intensity', + 'icon': 'mdi:molecule-co2', + 'state_class': , + 'unit_of_measurement': 'gCO2eq/kWh', + }), + 'context': , + 'entity_id': 'sensor.electricity_maps_co2_intensity', + 'last_changed': , + 'last_updated': , + 'state': '45.9862319009581', + }) +# --- +# name: test_sensor[sensor.electricity_maps_grid_fossil_fuel_percentage] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.electricity_maps_grid_fossil_fuel_percentage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:molecule-co2', + 'original_name': 'Grid fossil fuel percentage', + 'platform': 'co2signal', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fossil_fuel_percentage', + 'unique_id': '904a74160aa6f335526706bee85dfb83_fossilFuelPercentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.electricity_maps_grid_fossil_fuel_percentage].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Electricity Maps', + 'country_code': 'FR', + 'friendly_name': 'Electricity Maps Grid fossil fuel percentage', + 'icon': 'mdi:molecule-co2', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.electricity_maps_grid_fossil_fuel_percentage', + 'last_changed': , + 'last_updated': , + 'state': '5.4611827419371', + }) +# --- diff --git a/tests/components/co2signal/test_config_flow.py b/tests/components/co2signal/test_config_flow.py index 879293ae959..5b1ade1ee49 100644 --- a/tests/components/co2signal/test_config_flow.py +++ b/tests/components/co2signal/test_config_flow.py @@ -1,17 +1,23 @@ """Test the CO2 Signal config flow.""" -from json import JSONDecodeError -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, patch +from aioelectricitymaps.exceptions import ( + ElectricityMapsDecodeError, + ElectricityMapsError, + InvalidToken, +) import pytest from homeassistant import config_entries from homeassistant.components.co2signal import DOMAIN, config_flow +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import VALID_PAYLOAD +from tests.common import MockConfigEntry +@pytest.mark.usefixtures("electricity_maps") async def test_form_home(hass: HomeAssistant) -> None: """Test we get the form.""" @@ -22,9 +28,6 @@ async def test_form_home(hass: HomeAssistant) -> None: assert result["errors"] is None with patch( - "CO2Signal.get_latest", - return_value=VALID_PAYLOAD, - ), patch( "homeassistant.components.co2signal.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -45,6 +48,7 @@ async def test_form_home(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("electricity_maps") async def test_form_coordinates(hass: HomeAssistant) -> None: """Test we get the form.""" @@ -64,9 +68,6 @@ async def test_form_coordinates(hass: HomeAssistant) -> None: assert result2["type"] == FlowResultType.FORM with patch( - "CO2Signal.get_latest", - return_value=VALID_PAYLOAD, - ), patch( "homeassistant.components.co2signal.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -89,6 +90,7 @@ async def test_form_coordinates(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("electricity_maps") async def test_form_country(hass: HomeAssistant) -> None: """Test we get the form.""" @@ -108,9 +110,6 @@ async def test_form_country(hass: HomeAssistant) -> None: assert result2["type"] == FlowResultType.FORM with patch( - "CO2Signal.get_latest", - return_value=VALID_PAYLOAD, - ), patch( "homeassistant.components.co2signal.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -135,65 +134,95 @@ async def test_form_country(hass: HomeAssistant) -> None: ("side_effect", "err_code"), [ ( - ValueError("Invalid authentication credentials"), + InvalidToken, "invalid_auth", ), - ( - ValueError("API rate limit exceeded."), - "api_ratelimit", - ), - (ValueError("Something else"), "unknown"), - (JSONDecodeError(msg="boom", doc="", pos=1), "unknown"), - (Exception("Boom"), "unknown"), - (Mock(return_value={"error": "boom"}), "unknown"), - (Mock(return_value={"status": "error"}), "unknown"), + (ElectricityMapsError("Something else"), "unknown"), + (ElectricityMapsDecodeError("Boom"), "unknown"), ], ids=[ "invalid auth", - "rate limit exceeded", - "unknown value error", + "generic error", "json decode error", - "unknown error", - "error in json dict", - "status error", ], ) -async def test_form_error_handling(hass: HomeAssistant, side_effect, err_code) -> None: +async def test_form_error_handling( + hass: HomeAssistant, + electricity_maps: AsyncMock, + side_effect: Exception, + err_code: str, +) -> None: """Test we handle expected errors.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "CO2Signal.get_latest", - side_effect=side_effect, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "location": config_flow.TYPE_USE_HOME, - "api_key": "api_key", - }, - ) + electricity_maps.latest_carbon_intensity_by_coordinates.side_effect = side_effect + electricity_maps.latest_carbon_intensity_by_country_code.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "location": config_flow.TYPE_USE_HOME, + "api_key": "api_key", + }, + ) assert result["type"] == FlowResultType.FORM assert result["errors"] == {"base": err_code} - with patch( - "CO2Signal.get_latest", - return_value=VALID_PAYLOAD, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "location": config_flow.TYPE_USE_HOME, - "api_key": "api_key", - }, - ) - await hass.async_block_till_done() + # reset mock and test if now succeeds + electricity_maps.latest_carbon_intensity_by_coordinates.side_effect = None + electricity_maps.latest_carbon_intensity_by_country_code.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "location": config_flow.TYPE_USE_HOME, + "api_key": "api_key", + }, + ) + await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "CO2 Signal" assert result["data"] == { "api_key": "api_key", } + + +async def test_reauth( + hass: HomeAssistant, + config_entry: MockConfigEntry, + electricity_maps: AsyncMock, +) -> None: + """Test reauth flow.""" + config_entry.add_to_hass(hass) + + init_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=None, + ) + + assert init_result["type"] == FlowResultType.FORM + assert init_result["step_id"] == "reauth" + + with patch( + "homeassistant.components.co2signal.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + configure_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + { + CONF_API_KEY: "api_key2", + }, + ) + await hass.async_block_till_done() + + assert configure_result["type"] == FlowResultType.ABORT + assert configure_result["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/co2signal/test_diagnostics.py b/tests/components/co2signal/test_diagnostics.py index ed73cb960b5..edc0007952b 100644 --- a/tests/components/co2signal/test_diagnostics.py +++ b/tests/components/co2signal/test_diagnostics.py @@ -1,35 +1,23 @@ """Test the CO2Signal diagnostics.""" -from unittest.mock import patch +import pytest from syrupy import SnapshotAssertion -from homeassistant.components.co2signal import DOMAIN -from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from . import VALID_PAYLOAD from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +@pytest.mark.usefixtures("setup_integration") async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_API_KEY: "api_key", "location": ""}, - entry_id="904a74160aa6f335526706bee85dfb83", - ) - config_entry.add_to_hass(hass) - with patch("CO2Signal.get_latest", return_value=VALID_PAYLOAD): - assert await async_setup_component(hass, DOMAIN, {}) - result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) assert result == snapshot diff --git a/tests/components/co2signal/test_sensor.py b/tests/components/co2signal/test_sensor.py new file mode 100644 index 00000000000..b79c8e04c23 --- /dev/null +++ b/tests/components/co2signal/test_sensor.py @@ -0,0 +1,105 @@ +"""Tests Electricity Maps sensor platform.""" +from datetime import timedelta +from unittest.mock import AsyncMock + +from aioelectricitymaps.exceptions import ( + ElectricityMapsDecodeError, + ElectricityMapsError, + InvalidToken, +) +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import async_fire_time_changed + + +@pytest.mark.parametrize( + "entity_name", + [ + "sensor.electricity_maps_co2_intensity", + "sensor.electricity_maps_grid_fossil_fuel_percentage", + ], +) +@pytest.mark.usefixtures("setup_integration") +async def test_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + entity_name: str, + snapshot: SnapshotAssertion, +) -> None: + """Test sensor setup and update.""" + assert (entry := entity_registry.async_get(entity_name)) + assert entry == snapshot + + assert (state := hass.states.get(entity_name)) + assert state == snapshot + + +@pytest.mark.parametrize( + "error", + [ + ElectricityMapsDecodeError, + ElectricityMapsError, + Exception, + ], +) +@pytest.mark.usefixtures("setup_integration") +async def test_sensor_update_fail( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + electricity_maps: AsyncMock, + error: Exception, +) -> None: + """Test sensor error handling.""" + assert (state := hass.states.get("sensor.electricity_maps_co2_intensity")) + assert state.state == "45.9862319009581" + assert len(electricity_maps.mock_calls) == 1 + + electricity_maps.latest_carbon_intensity_by_coordinates.side_effect = error + electricity_maps.latest_carbon_intensity_by_country_code.side_effect = error + + freezer.tick(timedelta(minutes=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("sensor.electricity_maps_co2_intensity")) + assert state.state == "unavailable" + assert len(electricity_maps.mock_calls) == 2 + + # reset mock and test if entity is available again + electricity_maps.latest_carbon_intensity_by_coordinates.side_effect = None + electricity_maps.latest_carbon_intensity_by_country_code.side_effect = None + + freezer.tick(timedelta(minutes=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("sensor.electricity_maps_co2_intensity")) + assert state.state == "45.9862319009581" + assert len(electricity_maps.mock_calls) == 3 + + +@pytest.mark.usefixtures("setup_integration") +async def test_sensor_reauth_triggered( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + electricity_maps: AsyncMock, +): + """Test if reauth flow is triggered.""" + assert (state := hass.states.get("sensor.electricity_maps_co2_intensity")) + assert state.state == "45.9862319009581" + + electricity_maps.latest_carbon_intensity_by_coordinates.side_effect = InvalidToken + electricity_maps.latest_carbon_intensity_by_country_code.side_effect = InvalidToken + + freezer.tick(timedelta(minutes=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (flows := hass.config_entries.flow.async_progress()) + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth" diff --git a/tests/components/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py index f2d59f46114..dd15eca05cd 100644 --- a/tests/components/comelit/test_config_flow.py +++ b/tests/components/comelit/test_config_flow.py @@ -24,7 +24,7 @@ async def test_user(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.comelit.async_setup_entry" ) as mock_setup_entry, patch( - "requests.get" + "requests.get", ) as mock_request_get: mock_request_get.return_value.status_code = 200 @@ -70,7 +70,7 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> ), patch( "aiocomelit.api.ComeliteSerialBridgeApi.logout", ), patch( - "homeassistant.components.comelit.async_setup_entry" + "homeassistant.components.comelit.async_setup_entry", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_USER_DATA @@ -135,9 +135,7 @@ async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> "aiocomelit.api.ComeliteSerialBridgeApi.login", side_effect=side_effect ), patch( "aiocomelit.api.ComeliteSerialBridgeApi.logout", - ), patch( - "homeassistant.components.comelit.async_setup_entry" - ): + ), patch("homeassistant.components.comelit.async_setup_entry"): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_REAUTH, "entry_id": mock_config.entry_id}, diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py index 360c78dd5a7..eaa7061551a 100644 --- a/tests/components/command_line/test_binary_sensor.py +++ b/tests/components/command_line/test_binary_sensor.py @@ -9,7 +9,6 @@ from unittest.mock import patch import pytest from homeassistant import setup -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.command_line.binary_sensor import CommandBinarySensor from homeassistant.components.command_line.const import DOMAIN from homeassistant.components.homeassistant import ( @@ -19,39 +18,11 @@ from homeassistant.components.homeassistant import ( from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.issue_registry as ir from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed -async def test_setup_platform_yaml(hass: HomeAssistant) -> None: - """Test sensor setup.""" - assert await setup.async_setup_component( - hass, - BINARY_SENSOR_DOMAIN, - { - BINARY_SENSOR_DOMAIN: { - "platform": "command_line", - "name": "Test", - "command": "echo 1", - "payload_on": "1", - "payload_off": "0", - } - }, - ) - await hass.async_block_till_done() - - entity_state = hass.states.get("binary_sensor.test") - assert entity_state - assert entity_state.state == STATE_ON - assert entity_state.name == "Test" - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_binary_sensor") - assert issue.translation_key == "deprecated_platform_yaml" - - @pytest.mark.parametrize( "get_config", [ diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index 64fa2a60719..e6e428388f4 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -25,80 +25,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.issue_registry as ir import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed -async def test_no_covers_platform_yaml( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant -) -> None: - """Test that the cover does not polls when there's no state command.""" - - with patch( - "homeassistant.components.command_line.utils.subprocess.check_output", - return_value=b"50\n", - ): - assert await setup.async_setup_component( - hass, - COVER_DOMAIN, - { - COVER_DOMAIN: [ - {"platform": "command_line", "covers": {}}, - ] - }, - ) - await hass.async_block_till_done() - assert "No covers added" in caplog.text - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_cover") - assert issue.translation_key == "deprecated_platform_yaml" - - -async def test_state_value_platform_yaml(hass: HomeAssistant) -> None: - """Test with state value.""" - with tempfile.TemporaryDirectory() as tempdirname: - path = os.path.join(tempdirname, "cover_status") - assert await setup.async_setup_component( - hass, - COVER_DOMAIN, - { - COVER_DOMAIN: [ - { - "platform": "command_line", - "covers": { - "test": { - "command_state": f"cat {path}", - "command_open": f"echo 1 > {path}", - "command_close": f"echo 1 > {path}", - "command_stop": f"echo 0 > {path}", - "value_template": "{{ value }}", - "friendly_name": "Test", - }, - }, - }, - ] - }, - ) - await hass.async_block_till_done() - - entity_state = hass.states.get("cover.test") - assert entity_state - assert entity_state.state == "unknown" - - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.test"}, - blocking=True, - ) - entity_state = hass.states.get("cover.test") - assert entity_state - assert entity_state.state == "open" - - async def test_no_poll_when_cover_has_no_command_state(hass: HomeAssistant) -> None: """Test that the cover does not polls when there's no state command.""" diff --git a/tests/components/command_line/test_notify.py b/tests/components/command_line/test_notify.py index a17b1ec33e1..96ad5ce2ee8 100644 --- a/tests/components/command_line/test_notify.py +++ b/tests/components/command_line/test_notify.py @@ -12,26 +12,6 @@ from homeassistant import setup from homeassistant.components.command_line import DOMAIN from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.core import HomeAssistant -import homeassistant.helpers.issue_registry as ir - - -async def test_setup_platform_yaml(hass: HomeAssistant) -> None: - """Test sensor setup.""" - assert await setup.async_setup_component( - hass, - NOTIFY_DOMAIN, - { - NOTIFY_DOMAIN: [ - {"platform": "command_line", "name": "Test1", "command": "exit 0"}, - ] - }, - ) - await hass.async_block_till_done() - assert hass.services.has_service(NOTIFY_DOMAIN, "test1") - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_notify") - assert issue.translation_key == "deprecated_platform_yaml" @pytest.mark.parametrize( diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index 388d0345cad..9f28b8cc6d0 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -16,44 +16,14 @@ from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.issue_registry as ir from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed -async def test_setup_platform_yaml(hass: HomeAssistant) -> None: - """Test sensor setup.""" - assert await setup.async_setup_component( - hass, - SENSOR_DOMAIN, - { - SENSOR_DOMAIN: [ - { - "platform": "command_line", - "name": "Test", - "command": "echo 5", - "unit_of_measurement": "in", - }, - ] - }, - ) - await hass.async_block_till_done() - entity_state = hass.states.get("sensor.test") - assert entity_state - assert entity_state.state == "5" - assert entity_state.name == "Test" - assert entity_state.attributes["unit_of_measurement"] == "in" - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_sensor") - assert issue.translation_key == "deprecated_platform_yaml" - - @pytest.mark.parametrize( "get_config", [ diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index 09e8c47d708..f1f4096fa91 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -28,34 +28,27 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.issue_registry as ir import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed -async def test_state_platform_yaml(hass: HomeAssistant) -> None: +async def test_state_integration_yaml(hass: HomeAssistant) -> None: """Test with none state.""" with tempfile.TemporaryDirectory() as tempdirname: path = os.path.join(tempdirname, "switch_status") - assert await setup.async_setup_component( + await setup.async_setup_component( hass, - SWITCH_DOMAIN, + DOMAIN, { - SWITCH_DOMAIN: [ + "command_line": [ { - "platform": "command_line", - "switches": { - "test": { - "command_on": f"echo 1 > {path}", - "command_off": f"echo 0 > {path}", - "friendly_name": "Test", - "icon_template": ( - '{% if value=="1" %} mdi:on {% else %} mdi:off {% endif %}' - ), - } - }, - }, + "switch": { + "command_on": f"echo 1 > {path}", + "command_off": f"echo 0 > {path}", + "name": "Test", + } + } ] }, ) @@ -87,36 +80,6 @@ async def test_state_platform_yaml(hass: HomeAssistant) -> None: assert entity_state assert entity_state.state == STATE_OFF - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_switch") - assert issue.translation_key == "deprecated_platform_yaml" - - -async def test_state_integration_yaml(hass: HomeAssistant) -> None: - """Test with none state.""" - with tempfile.TemporaryDirectory() as tempdirname: - path = os.path.join(tempdirname, "switch_status") - await setup.async_setup_component( - hass, - DOMAIN, - { - "command_line": [ - { - "switch": { - "command_on": f"echo 1 > {path}", - "command_off": f"echo 0 > {path}", - "name": "Test", - } - } - ] - }, - ) - await hass.async_block_till_done() - - entity_state = hass.states.get("switch.test") - assert entity_state - assert entity_state.state == STATE_OFF - async def test_state_value(hass: HomeAssistant) -> None: """Test with state value.""" @@ -487,27 +450,6 @@ async def test_switch_command_state_value_exceptions( assert "Error trying to exec command" in caplog.text -async def test_no_switches_platform_yaml( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant -) -> None: - """Test with no switches.""" - - assert await setup.async_setup_component( - hass, - SWITCH_DOMAIN, - { - SWITCH_DOMAIN: [ - { - "platform": "command_line", - "switches": {}, - }, - ] - }, - ) - await hass.async_block_till_done() - assert "No switches" in caplog.text - - async def test_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py index abe0ed90e86..1a099c05b16 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -23,7 +23,9 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture async def setup_automation( - hass, automation_config, stub_blueprint_populate # noqa: F811 + hass, + automation_config, + stub_blueprint_populate, # noqa: F811 ): """Set up automation integration.""" assert await async_setup_component( @@ -337,13 +339,13 @@ async def test_bad_formatted_automations( async def test_delete_automation( hass: HomeAssistant, hass_client: ClientSessionGenerator, + entity_registry: er.EntityRegistry, hass_config_store, setup_automation, ) -> None: """Test deleting an automation.""" - ent_reg = er.async_get(hass) - assert len(ent_reg.entities) == 2 + assert len(entity_registry.entities) == 2 with patch.object(config, "SECTIONS", ["automation"]): assert await async_setup_component(hass, "config", {}) @@ -371,7 +373,7 @@ async def test_delete_automation( assert hass_config_store["automations.yaml"] == [{"id": "moon"}] - assert len(ent_reg.entities) == 1 + assert len(entity_registry.entities) == 1 @pytest.mark.parametrize("automation_config", ({},)) diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 3cc7ada49ba..bfee7551cff 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -19,8 +19,8 @@ from tests.common import ( MockConfigEntry, MockModule, MockUser, - mock_entity_platform, mock_integration, + mock_platform, ) from tests.typing import WebSocketGenerator @@ -304,7 +304,7 @@ async def test_reload_entry_in_setup_retry( async_migrate_entry=mock_migrate_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) entry = MockConfigEntry(domain="comp", state=core_ce.ConfigEntryState.SETUP_RETRY) entry.supports_unload = True entry.add_to_hass(hass) @@ -353,7 +353,7 @@ async def test_available_flows( async def test_initialize_flow(hass: HomeAssistant, client) -> None: """Test we can initialize a flow.""" - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) class TestFlow(core_ce.ConfigFlow): async def async_step_user(self, user_input=None): @@ -402,7 +402,7 @@ async def test_initialize_flow(hass: HomeAssistant, client) -> None: async def test_initialize_flow_unmet_dependency(hass: HomeAssistant, client) -> None: """Test unmet dependencies are listed.""" - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) config_schema = vol.Schema({"comp_conf": {"hello": str}}, required=True) mock_integration( @@ -458,7 +458,7 @@ async def test_initialize_flow_unauth( async def test_abort(hass: HomeAssistant, client) -> None: """Test a flow that aborts.""" - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) class TestFlow(core_ce.ConfigFlow): async def async_step_user(self, user_input=None): @@ -484,7 +484,7 @@ async def test_create_account( hass: HomeAssistant, client, enable_custom_integrations: None ) -> None: """Test a flow that creates an account.""" - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) mock_integration( hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) @@ -542,7 +542,7 @@ async def test_two_step_flow( mock_integration( hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) ) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) class TestFlow(core_ce.ConfigFlow): VERSION = 1 @@ -619,7 +619,7 @@ async def test_continue_flow_unauth( mock_integration( hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) ) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) class TestFlow(core_ce.ConfigFlow): VERSION = 1 @@ -666,7 +666,7 @@ async def test_get_progress_index( ) -> None: """Test querying for the flows that are in progress.""" assert await async_setup_component(hass, "config", {}) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) ws_client = await hass_ws_client(hass) class TestFlow(core_ce.ConfigFlow): @@ -714,7 +714,7 @@ async def test_get_progress_index_unauth( async def test_get_progress_flow(hass: HomeAssistant, client) -> None: """Test we can query the API for same result as we get from init a flow.""" - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) class TestFlow(core_ce.ConfigFlow): async def async_step_user(self, user_input=None): @@ -750,7 +750,7 @@ async def test_get_progress_flow_unauth( hass: HomeAssistant, client, hass_admin_user: MockUser ) -> None: """Test we can can't query the API for result of flow.""" - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) class TestFlow(core_ce.ConfigFlow): async def async_step_user(self, user_input=None): @@ -804,7 +804,7 @@ async def test_options_flow(hass: HomeAssistant, client) -> None: return OptionsFlowHandler() mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) MockConfigEntry( domain="test", entry_id="test1", @@ -862,7 +862,7 @@ async def test_options_flow_unauth( return OptionsFlowHandler() mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) MockConfigEntry( domain="test", entry_id="test1", @@ -883,7 +883,7 @@ async def test_two_step_options_flow(hass: HomeAssistant, client) -> None: mock_integration( hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) ) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) class TestFlow(core_ce.ConfigFlow): @staticmethod @@ -950,7 +950,7 @@ async def test_options_flow_with_invalid_data(hass: HomeAssistant, client) -> No mock_integration( hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) ) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) class TestFlow(core_ce.ConfigFlow): @staticmethod @@ -1265,7 +1265,7 @@ async def test_ignore_flow( mock_integration( hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) ) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) class TestFlow(core_ce.ConfigFlow): VERSION = 1 diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py index fa7f33858a6..bd21e5e7d30 100644 --- a/tests/components/config/test_core.py +++ b/tests/components/config/test_core.py @@ -1,6 +1,6 @@ """Test core config.""" from http import HTTPStatus -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest @@ -37,9 +37,14 @@ async def test_validate_config_ok( client = await hass_client() + no_error = Mock() + no_error.errors = None + no_error.error_str = "" + no_error.warning_str = "" + with patch( - "homeassistant.components.config.core.async_check_ha_config_file", - return_value=None, + "homeassistant.components.config.core.check_config.async_check_ha_config_file", + return_value=no_error, ): resp = await client.post("/api/config/core/check_config") @@ -47,10 +52,16 @@ async def test_validate_config_ok( result = await resp.json() assert result["result"] == "valid" assert result["errors"] is None + assert result["warnings"] is None + + error_warning = Mock() + error_warning.errors = ["beer"] + error_warning.error_str = "beer" + error_warning.warning_str = "milk" with patch( - "homeassistant.components.config.core.async_check_ha_config_file", - return_value="beer", + "homeassistant.components.config.core.check_config.async_check_ha_config_file", + return_value=error_warning, ): resp = await client.post("/api/config/core/check_config") @@ -58,6 +69,24 @@ async def test_validate_config_ok( result = await resp.json() assert result["result"] == "invalid" assert result["errors"] == "beer" + assert result["warnings"] == "milk" + + warning = Mock() + warning.errors = None + warning.error_str = "" + warning.warning_str = "milk" + + with patch( + "homeassistant.components.config.core.check_config.async_check_ha_config_file", + return_value=warning, + ): + resp = await client.post("/api/config/core/check_config") + + assert resp.status == HTTPStatus.OK + result = await resp.json() + assert result["result"] == "valid" + assert result["errors"] is None + assert result["warnings"] == "milk" async def test_validate_config_requires_admin( diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 87bb9cc9409..4a784a6eff1 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -242,7 +242,7 @@ async def test_remove_config_entry_from_device( response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" # Make async_remove_config_entry_device return True can_remove = True @@ -365,7 +365,7 @@ async def test_remove_config_entry_from_device_fails( response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Unknown config entry" # Try removing a config entry which does not support removal from the device @@ -380,7 +380,7 @@ async def test_remove_config_entry_from_device_fails( response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" assert ( response["error"]["message"] == "Config entry does not support device removal" ) @@ -397,7 +397,7 @@ async def test_remove_config_entry_from_device_fails( response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Unknown device" # Try removing a config entry from a device which it's not connected to @@ -428,7 +428,7 @@ async def test_remove_config_entry_from_device_fails( response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Config entry not in device" # Try removing a config entry which can't be loaded from a device - allowed @@ -443,5 +443,5 @@ async def test_remove_config_entry_from_device_fails( response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Integration not found" diff --git a/tests/components/config/test_scene.py b/tests/components/config/test_scene.py index 1f09d5e9989..9fd596f7f91 100644 --- a/tests/components/config/test_scene.py +++ b/tests/components/config/test_scene.py @@ -184,13 +184,13 @@ async def test_bad_formatted_scene( async def test_delete_scene( hass: HomeAssistant, hass_client: ClientSessionGenerator, + entity_registry: er.EntityRegistry, hass_config_store, setup_scene, ) -> None: """Test deleting a scene.""" - ent_reg = er.async_get(hass) - assert len(ent_reg.entities) == 2 + assert len(entity_registry.entities) == 2 with patch.object(config, "SECTIONS", ["scene"]): assert await async_setup_component(hass, "config", {}) @@ -220,7 +220,7 @@ async def test_delete_scene( {"id": "light_off"}, ] - assert len(ent_reg.entities) == 1 + assert len(entity_registry.entities) == 1 @pytest.mark.parametrize("scene_config", ({},)) diff --git a/tests/components/config/test_script.py b/tests/components/config/test_script.py index cc0352301b4..7cf8cf5833e 100644 --- a/tests/components/config/test_script.py +++ b/tests/components/config/test_script.py @@ -281,7 +281,10 @@ async def test_update_remove_key_script_config( ), ) async def test_delete_script( - hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_config_store + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + entity_registry: er.EntityRegistry, + hass_config_store, ) -> None: """Test deleting a script.""" with patch.object(config, "SECTIONS", ["script"]): @@ -292,8 +295,7 @@ async def test_delete_script( "script.two", ] - ent_reg = er.async_get(hass) - assert len(ent_reg.entities) == 2 + assert len(entity_registry.entities) == 2 client = await hass_client() @@ -313,7 +315,7 @@ async def test_delete_script( assert hass_config_store["scripts.yaml"] == {"one": {}} - assert len(ent_reg.entities) == 1 + assert len(entity_registry.entities) == 1 @pytest.mark.parametrize("script_config", ({},)) diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index c75c96ca59b..fe94e2d5425 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -1,4 +1,5 @@ """Test for the default agent.""" +from collections import defaultdict from unittest.mock import AsyncMock, patch import pytest @@ -293,3 +294,124 @@ async def test_nevermind_item(hass: HomeAssistant, init_components) -> None: assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert not result.response.speech + + +async def test_device_area_context( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that including a device_id will target a specific area.""" + turn_on_calls = async_mock_service(hass, "light", "turn_on") + turn_off_calls = async_mock_service(hass, "light", "turn_off") + + area_kitchen = area_registry.async_get_or_create("kitchen") + area_bedroom = area_registry.async_get_or_create("bedroom") + + # Create 2 lights in each area + area_lights = defaultdict(list) + for area in (area_kitchen, area_bedroom): + for i in range(2): + light_entity = entity_registry.async_get_or_create( + "light", "demo", f"{area.name}-light-{i}" + ) + entity_registry.async_update_entity(light_entity.entity_id, area_id=area.id) + hass.states.async_set( + light_entity.entity_id, + "off", + attributes={ATTR_FRIENDLY_NAME: f"{area.name} light {i}"}, + ) + area_lights[area.name].append(light_entity) + + # Create voice satellites in each area + entry = MockConfigEntry() + entry.add_to_hass(hass) + + kitchen_satellite = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("demo", "id-satellite-kitchen")}, + ) + device_registry.async_update_device(kitchen_satellite.id, area_id=area_kitchen.id) + + bedroom_satellite = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("demo", "id-satellite-bedroom")}, + ) + device_registry.async_update_device(bedroom_satellite.id, area_id=area_bedroom.id) + + # Turn on lights in the area of a device + result = await conversation.async_converse( + hass, + "turn on the lights", + None, + Context(), + None, + device_id=kitchen_satellite.id, + ) + await hass.async_block_till_done() + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + + # Verify only kitchen lights were targeted + assert {s.entity_id for s in result.response.matched_states} == { + e.entity_id for e in area_lights["kitchen"] + } + assert {c.data["entity_id"][0] for c in turn_on_calls} == { + e.entity_id for e in area_lights["kitchen"] + } + turn_on_calls.clear() + + # Ensure we can still target other areas by name + result = await conversation.async_converse( + hass, + "turn on lights in the bedroom", + None, + Context(), + None, + device_id=kitchen_satellite.id, + ) + await hass.async_block_till_done() + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + + # Verify only bedroom lights were targeted + assert {s.entity_id for s in result.response.matched_states} == { + e.entity_id for e in area_lights["bedroom"] + } + assert {c.data["entity_id"][0] for c in turn_on_calls} == { + e.entity_id for e in area_lights["bedroom"] + } + turn_on_calls.clear() + + # Turn off all lights in the area of the otherkj device + result = await conversation.async_converse( + hass, + "turn lights off", + None, + Context(), + None, + device_id=bedroom_satellite.id, + ) + await hass.async_block_till_done() + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + + # Verify only bedroom lights were targeted + assert {s.entity_id for s in result.response.matched_states} == { + e.entity_id for e in area_lights["bedroom"] + } + assert {c.data["entity_id"][0] for c in turn_off_calls} == { + e.entity_id for e in area_lights["bedroom"] + } + turn_off_calls.clear() + + # Not providing a device id should not match + for command in ("on", "off"): + result = await conversation.async_converse( + hass, f"turn {command} all lights", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.ERROR + assert ( + result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH + ) diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 37c8f9401bc..fdbf10b0c7f 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -1307,7 +1307,14 @@ async def test_prepare_reload(hass: HomeAssistant) -> None: # Confirm intents are loaded assert agent._lang_intents.get(language) - # Clear cache + # Try to clear for a different language + await hass.services.async_call("conversation", "reload", {"language": "elvish"}) + await hass.async_block_till_done() + + # Confirm intents are still loaded + assert agent._lang_intents.get(language) + + # Clear cache for all languages await hass.services.async_call("conversation", "reload", {}) await hass.async_block_till_done() diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py index 3f4dd9e3a7e..4fe9fed6bb2 100644 --- a/tests/components/conversation/test_trigger.py +++ b/tests/components/conversation/test_trigger.py @@ -194,6 +194,42 @@ async def test_fails_on_punctuation(hass: HomeAssistant, command: str) -> None: ) +@pytest.mark.parametrize( + "command", + [""], +) +async def test_fails_on_empty(hass: HomeAssistant, command: str) -> None: + """Test that validation fails when sentences are empty.""" + with pytest.raises(vol.Invalid): + await trigger.async_validate_trigger_config( + hass, + [ + { + "id": "trigger1", + "platform": "conversation", + "command": [ + command, + ], + }, + ], + ) + + +async def test_fails_on_no_sentences(hass: HomeAssistant) -> None: + """Test that validation fails when no sentences are provided.""" + with pytest.raises(vol.Invalid): + await trigger.async_validate_trigger_config( + hass, + [ + { + "id": "trigger1", + "platform": "conversation", + "command": [], + }, + ], + ) + + async def test_wildcards(hass: HomeAssistant, calls, setup_comp) -> None: """Test wildcards in trigger sentences.""" assert await async_setup_component( diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py index 097102a341e..53bec13d567 100644 --- a/tests/components/counter/test_init.py +++ b/tests/components/counter/test_init.py @@ -24,7 +24,7 @@ from homeassistant.components.counter import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_NAME from homeassistant.core import Context, CoreState, HomeAssistant, State -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from .common import async_decrement, async_increment, async_reset @@ -432,148 +432,6 @@ async def test_counter_max(hass: HomeAssistant, hass_admin_user: MockUser) -> No assert state2.state == "-1" -async def test_configure( - hass: HomeAssistant, hass_admin_user: MockUser, issue_registry: ir.IssueRegistry -) -> None: - """Test that setting values through configure works.""" - assert await async_setup_component( - hass, "counter", {"counter": {"test": {"maximum": "10", "initial": "10"}}} - ) - - state = hass.states.get("counter.test") - assert state is not None - assert state.state == "10" - assert state.attributes.get("maximum") == 10 - - # update max - await hass.services.async_call( - "counter", - "configure", - {"entity_id": state.entity_id, "maximum": 0}, - True, - Context(user_id=hass_admin_user.id), - ) - - state = hass.states.get("counter.test") - assert state is not None - assert state.state == "0" - assert state.attributes.get("maximum") == 0 - - # Ensure an issue is raised for the use of this deprecated service - assert issue_registry.async_get_issue( - domain=DOMAIN, issue_id="deprecated_configure_service" - ) - - # disable max - await hass.services.async_call( - "counter", - "configure", - {"entity_id": state.entity_id, "maximum": None}, - True, - Context(user_id=hass_admin_user.id), - ) - - state = hass.states.get("counter.test") - assert state is not None - assert state.state == "0" - assert state.attributes.get("maximum") is None - - # update min - assert state.attributes.get("minimum") is None - await hass.services.async_call( - "counter", - "configure", - {"entity_id": state.entity_id, "minimum": 5}, - True, - Context(user_id=hass_admin_user.id), - ) - - state = hass.states.get("counter.test") - assert state is not None - assert state.state == "5" - assert state.attributes.get("minimum") == 5 - - # disable min - await hass.services.async_call( - "counter", - "configure", - {"entity_id": state.entity_id, "minimum": None}, - True, - Context(user_id=hass_admin_user.id), - ) - - state = hass.states.get("counter.test") - assert state is not None - assert state.state == "5" - assert state.attributes.get("minimum") is None - - # update step - assert state.attributes.get("step") == 1 - await hass.services.async_call( - "counter", - "configure", - {"entity_id": state.entity_id, "step": 3}, - True, - Context(user_id=hass_admin_user.id), - ) - - state = hass.states.get("counter.test") - assert state is not None - assert state.state == "5" - assert state.attributes.get("step") == 3 - - # update value - await hass.services.async_call( - "counter", - "configure", - {"entity_id": state.entity_id, "value": 6}, - True, - Context(user_id=hass_admin_user.id), - ) - - state = hass.states.get("counter.test") - assert state is not None - assert state.state == "6" - - # update initial - await hass.services.async_call( - "counter", - "configure", - {"entity_id": state.entity_id, "initial": 5}, - True, - Context(user_id=hass_admin_user.id), - ) - - state = hass.states.get("counter.test") - assert state is not None - assert state.state == "6" - assert state.attributes.get("initial") == 5 - - # update all - await hass.services.async_call( - "counter", - "configure", - { - "entity_id": state.entity_id, - "step": 5, - "minimum": 0, - "maximum": 9, - "value": 5, - "initial": 6, - }, - True, - Context(user_id=hass_admin_user.id), - ) - - state = hass.states.get("counter.test") - assert state is not None - assert state.state == "5" - assert state.attributes.get("step") == 5 - assert state.attributes.get("minimum") == 0 - assert state.attributes.get("maximum") == 9 - assert state.attributes.get("initial") == 6 - - async def test_load_from_storage(hass: HomeAssistant, storage_setup) -> None: """Test set up from storage.""" assert await storage_setup() @@ -644,18 +502,20 @@ async def test_ws_list( async def test_ws_delete( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, + storage_setup, ) -> None: """Test WS delete cleans up entity registry.""" assert await storage_setup() input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -667,7 +527,7 @@ async def test_ws_delete( state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None async def test_update_min_max( @@ -688,7 +548,7 @@ async def test_update_min_max( input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) + entity_registry = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is not None @@ -696,7 +556,7 @@ async def test_update_min_max( assert state.attributes[ATTR_MAXIMUM] == 100 assert state.attributes[ATTR_MINIMUM] == 10 assert state.attributes[ATTR_STEP] == 3 - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -769,11 +629,11 @@ async def test_create( counter_id = "new_counter" input_entity_id = f"{DOMAIN}.{counter_id}" - ent_reg = er.async_get(hass) + entity_registry = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, counter_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, counter_id) is None client = await hass_ws_client(hass) diff --git a/tests/components/counter/test_reproduce_state.py b/tests/components/counter/test_reproduce_state.py index dfd4f95bec2..44d0eca4d72 100644 --- a/tests/components/counter/test_reproduce_state.py +++ b/tests/components/counter/test_reproduce_state.py @@ -15,10 +15,10 @@ async def test_reproducing_states( hass.states.async_set( "counter.entity_attr", "8", - {"initial": 12, "minimum": 5, "maximum": 15, "step": 3}, + {"minimum": 5, "maximum": 15, "step": 3}, ) - configure_calls = async_mock_service(hass, "counter", "configure") + configure_calls = async_mock_service(hass, "counter", "set_value") # These calls should do nothing as entities already in desired state await async_reproduce_state( @@ -28,7 +28,7 @@ async def test_reproducing_states( State( "counter.entity_attr", "8", - {"initial": 12, "minimum": 5, "maximum": 15, "step": 3}, + {"minimum": 5, "maximum": 15, "step": 3}, ), ], ) @@ -49,7 +49,7 @@ async def test_reproducing_states( State( "counter.entity_attr", "7", - {"initial": 10, "minimum": 3, "maximum": 21, "step": 5}, + {"minimum": 3, "maximum": 21, "step": 5}, ), # Should not raise State("counter.non_existing", "6"), @@ -61,7 +61,6 @@ async def test_reproducing_states( { "entity_id": "counter.entity_attr", "value": "7", - "initial": 10, "minimum": 3, "maximum": 21, "step": 5, diff --git a/tests/components/cpuspeed/test_sensor.py b/tests/components/cpuspeed/test_sensor.py index 625f80a6814..457d9c37d14 100644 --- a/tests/components/cpuspeed/test_sensor.py +++ b/tests/components/cpuspeed/test_sensor.py @@ -25,13 +25,13 @@ from tests.common import MockConfigEntry async def test_sensor( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, mock_cpuinfo: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test the CPU Speed sensor.""" await async_setup_component(hass, "homeassistant", {}) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) entry = entity_registry.async_get("sensor.cpu_speed") assert entry diff --git a/tests/components/daikin/test_init.py b/tests/components/daikin/test_init.py index 3b5f81ae2e5..857d9e399f4 100644 --- a/tests/components/daikin/test_init.py +++ b/tests/components/daikin/test_init.py @@ -50,7 +50,12 @@ DATA = { INVALID_DATA = {**DATA, "name": None, "mac": HOST} -async def test_duplicate_removal(hass: HomeAssistant, mock_daikin) -> None: +async def test_duplicate_removal( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_daikin, +) -> None: """Test duplicate device removal.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -59,8 +64,6 @@ async def test_duplicate_removal(hass: HomeAssistant, mock_daikin) -> None: data={CONF_HOST: HOST, KEY_MAC: HOST}, ) config_entry.add_to_hass(hass) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) type(mock_daikin).mac = PropertyMock(return_value=HOST) type(mock_daikin).values = PropertyMock(return_value=INVALID_DATA) @@ -111,7 +114,12 @@ async def test_duplicate_removal(hass: HomeAssistant, mock_daikin) -> None: assert entity_registry.async_get("switch.none_zone_1").unique_id.startswith(MAC) -async def test_unique_id_migrate(hass: HomeAssistant, mock_daikin) -> None: +async def test_unique_id_migrate( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_daikin, +) -> None: """Test unique id migration.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -120,8 +128,6 @@ async def test_unique_id_migrate(hass: HomeAssistant, mock_daikin) -> None: data={CONF_HOST: HOST, KEY_MAC: HOST}, ) config_entry.add_to_hass(hass) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) type(mock_daikin).mac = PropertyMock(return_value=HOST) type(mock_daikin).values = PropertyMock(return_value=INVALID_DATA) @@ -171,7 +177,6 @@ async def test_client_update_connection_error( data={CONF_HOST: HOST, KEY_MAC: MAC}, ) config_entry.add_to_hass(hass) - er.async_get(hass) type(mock_daikin).mac = PropertyMock(return_value=MAC) type(mock_daikin).values = PropertyMock(return_value=DATA) diff --git a/tests/components/deconz/test_binary_sensor.py b/tests/components/deconz/test_binary_sensor.py index e9556fe4b5d..68396c8ff9c 100644 --- a/tests/components/deconz/test_binary_sensor.py +++ b/tests/components/deconz/test_binary_sensor.py @@ -480,17 +480,17 @@ TEST_DATA = [ @pytest.mark.parametrize(("sensor_data", "expected"), TEST_DATA) async def test_binary_sensors( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, aioclient_mock: AiohttpClientMocker, mock_deconz_websocket, sensor_data, expected, ) -> None: """Test successful creation of binary sensor entities.""" - ent_reg = er.async_get(hass) - dev_reg = dr.async_get(hass) # Create entity entry to migrate to new unique ID - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( DOMAIN, DECONZ_DOMAIN, expected["old_unique_id"], @@ -513,14 +513,14 @@ async def test_binary_sensors( # Verify entity registry data - ent_reg_entry = ent_reg.async_get(expected["entity_id"]) + ent_reg_entry = entity_registry.async_get(expected["entity_id"]) assert ent_reg_entry.entity_category is expected["entity_category"] assert ent_reg_entry.unique_id == expected["unique_id"] # Verify device registry data assert ( - len(dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id)) + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) == expected["device_count"] ) @@ -670,7 +670,10 @@ async def test_add_new_binary_sensor( async def test_add_new_binary_sensor_ignored_load_entities_on_service_call( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_deconz_websocket + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, + mock_deconz_websocket, ) -> None: """Test that adding a new binary sensor is not allowed.""" sensor = { @@ -702,7 +705,6 @@ async def test_add_new_binary_sensor_ignored_load_entities_on_service_call( assert len(hass.states.async_all()) == 0 assert not hass.states.get("binary_sensor.presence_sensor") - entity_registry = er.async_get(hass) assert ( len(async_entries_for_config_entry(entity_registry, config_entry.entry_id)) == 0 ) @@ -719,7 +721,10 @@ async def test_add_new_binary_sensor_ignored_load_entities_on_service_call( async def test_add_new_binary_sensor_ignored_load_entities_on_options_change( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_deconz_websocket + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, + mock_deconz_websocket, ) -> None: """Test that adding a new binary sensor is not allowed.""" sensor = { @@ -751,7 +756,6 @@ async def test_add_new_binary_sensor_ignored_load_entities_on_options_change( assert len(hass.states.async_all()) == 0 assert not hass.states.get("binary_sensor.presence_sensor") - entity_registry = er.async_get(hass) assert ( len(async_entries_for_config_entry(entity_registry, config_entry.entry_id)) == 0 ) diff --git a/tests/components/deconz/test_button.py b/tests/components/deconz/test_button.py index 87e80374e11..7f4dd59bf16 100644 --- a/tests/components/deconz/test_button.py +++ b/tests/components/deconz/test_button.py @@ -101,12 +101,14 @@ TEST_DATA = [ @pytest.mark.parametrize(("raw_data", "expected"), TEST_DATA) async def test_button( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, raw_data, expected + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + aioclient_mock: AiohttpClientMocker, + raw_data, + expected, ) -> None: """Test successful creation of button entities.""" - ent_reg = er.async_get(hass) - dev_reg = dr.async_get(hass) - with patch.dict(DECONZ_WEB_REQUEST, raw_data): config_entry = await setup_deconz_integration(hass, aioclient_mock) @@ -119,14 +121,14 @@ async def test_button( # Verify entity registry data - ent_reg_entry = ent_reg.async_get(expected["entity_id"]) + ent_reg_entry = entity_registry.async_get(expected["entity_id"]) assert ent_reg_entry.entity_category is expected["entity_category"] assert ent_reg_entry.unique_id == expected["unique_id"] # Verify device registry data assert ( - len(dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id)) + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) == expected["device_count"] ) diff --git a/tests/components/deconz/test_deconz_event.py b/tests/components/deconz/test_deconz_event.py index f32fec7e486..403feb07915 100644 --- a/tests/components/deconz/test_deconz_event.py +++ b/tests/components/deconz/test_deconz_event.py @@ -34,7 +34,10 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_deconz_events( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_deconz_websocket + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + aioclient_mock: AiohttpClientMocker, + mock_deconz_websocket, ) -> None: """Test successful creation of deconz events.""" data = { @@ -79,8 +82,6 @@ async def test_deconz_events( with patch.dict(DECONZ_WEB_REQUEST, data): config_entry = await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) - assert len(hass.states.async_all()) == 3 # 5 switches + 2 additional devices for deconz service and host assert ( @@ -212,7 +213,10 @@ async def test_deconz_events( async def test_deconz_alarm_events( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_deconz_websocket + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + aioclient_mock: AiohttpClientMocker, + mock_deconz_websocket, ) -> None: """Test successful creation of deconz alarm events.""" data = { @@ -276,8 +280,6 @@ async def test_deconz_alarm_events( with patch.dict(DECONZ_WEB_REQUEST, data): config_entry = await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) - assert len(hass.states.async_all()) == 4 # 1 alarm control device + 2 additional devices for deconz service and host assert ( @@ -424,7 +426,10 @@ async def test_deconz_alarm_events( async def test_deconz_presence_events( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_deconz_websocket + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + aioclient_mock: AiohttpClientMocker, + mock_deconz_websocket, ) -> None: """Test successful creation of deconz presence events.""" data = { @@ -457,8 +462,6 @@ async def test_deconz_presence_events( with patch.dict(DECONZ_WEB_REQUEST, data): config_entry = await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) - assert len(hass.states.async_all()) == 5 assert ( len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) @@ -527,7 +530,10 @@ async def test_deconz_presence_events( async def test_deconz_relative_rotary_events( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_deconz_websocket + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + aioclient_mock: AiohttpClientMocker, + mock_deconz_websocket, ) -> None: """Test successful creation of deconz relative rotary events.""" data = { @@ -559,8 +565,6 @@ async def test_deconz_relative_rotary_events( with patch.dict(DECONZ_WEB_REQUEST, data): config_entry = await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) - assert len(hass.states.async_all()) == 1 assert ( len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) @@ -626,7 +630,9 @@ async def test_deconz_relative_rotary_events( async def test_deconz_events_bad_unique_id( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Verify no devices are created if unique id is bad or missing.""" data = { @@ -649,8 +655,6 @@ async def test_deconz_events_bad_unique_id( with patch.dict(DECONZ_WEB_REQUEST, data): config_entry = await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) - assert len(hass.states.async_all()) == 1 assert ( len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) diff --git a/tests/components/deconz/test_device_trigger.py b/tests/components/deconz/test_device_trigger.py index fe9d57f8a65..4c3344f5822 100644 --- a/tests/components/deconz/test_device_trigger.py +++ b/tests/components/deconz/test_device_trigger.py @@ -49,7 +49,10 @@ def automation_calls(hass): async def test_get_triggers( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test triggers work.""" data = { @@ -78,11 +81,9 @@ async def test_get_triggers( with patch.dict(DECONZ_WEB_REQUEST, data): await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={(DECONZ_DOMAIN, "d0:cf:5e:ff:fe:71:a4:3a")} ) - entity_registry = er.async_get(hass) battery_sensor_entry = entity_registry.async_get( "sensor.tradfri_on_off_switch_battery" ) @@ -154,7 +155,10 @@ async def test_get_triggers( async def test_get_triggers_for_alarm_event( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test triggers work.""" data = { @@ -190,11 +194,9 @@ async def test_get_triggers_for_alarm_event( with patch.dict(DECONZ_WEB_REQUEST, data): await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={(DECONZ_DOMAIN, "00:00:00:00:00:00:00:00")} ) - entity_registry = er.async_get(hass) bat_entity = entity_registry.async_get("sensor.keypad_battery") low_bat_entity = entity_registry.async_get("binary_sensor.keypad_low_battery") tamper_entity = entity_registry.async_get("binary_sensor.keypad_tampered") @@ -250,7 +252,9 @@ async def test_get_triggers_for_alarm_event( async def test_get_triggers_manage_unsupported_remotes( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, ) -> None: """Verify no triggers for an unsupported remote.""" data = { @@ -278,7 +282,6 @@ async def test_get_triggers_manage_unsupported_remotes( with patch.dict(DECONZ_WEB_REQUEST, data): await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={(DECONZ_DOMAIN, "d0:cf:5e:ff:fe:71:a4:3a")} ) @@ -297,6 +300,7 @@ async def test_functional_device_trigger( aioclient_mock: AiohttpClientMocker, mock_deconz_websocket, automation_calls, + device_registry: dr.DeviceRegistry, ) -> None: """Test proper matching and attachment of device trigger automation.""" @@ -326,7 +330,6 @@ async def test_functional_device_trigger( with patch.dict(DECONZ_WEB_REQUEST, data): await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={(DECONZ_DOMAIN, "d0:cf:5e:ff:fe:71:a4:3a")} ) @@ -403,12 +406,13 @@ async def test_validate_trigger_unknown_device( async def test_validate_trigger_unsupported_device( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, ) -> None: """Test unsupported device doesn't return a trigger config.""" config_entry = await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DECONZ_DOMAIN, "d0:cf:5e:ff:fe:71:a4:3a")}, @@ -444,12 +448,13 @@ async def test_validate_trigger_unsupported_device( async def test_validate_trigger_unsupported_trigger( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, ) -> None: """Test unsupported trigger does not return a trigger config.""" config_entry = await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DECONZ_DOMAIN, "d0:cf:5e:ff:fe:71:a4:3a")}, @@ -487,12 +492,13 @@ async def test_validate_trigger_unsupported_trigger( async def test_attach_trigger_no_matching_event( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, ) -> None: """Test no matching event for device doesn't return a trigger config.""" config_entry = await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DECONZ_DOMAIN, "d0:cf:5e:ff:fe:71:a4:3a")}, diff --git a/tests/components/deconz/test_gateway.py b/tests/components/deconz/test_gateway.py index 3ac682b78a6..cc5d2520f5d 100644 --- a/tests/components/deconz/test_gateway.py +++ b/tests/components/deconz/test_gateway.py @@ -139,7 +139,9 @@ async def setup_deconz_integration( async def test_gateway_setup( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, ) -> None: """Successful setup.""" with patch( @@ -178,7 +180,6 @@ async def test_gateway_setup( assert forward_entry_setup.mock_calls[12][1] == (config_entry, SIREN_DOMAIN) assert forward_entry_setup.mock_calls[13][1] == (config_entry, SWITCH_DOMAIN) - device_registry = dr.async_get(hass) gateway_entry = device_registry.async_get_device( identifiers={(DECONZ_DOMAIN, gateway.bridgeid)} ) @@ -188,7 +189,9 @@ async def test_gateway_setup( async def test_gateway_device_configuration_url_when_addon( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, ) -> None: """Successful setup.""" with patch( @@ -200,7 +203,6 @@ async def test_gateway_device_configuration_url_when_addon( ) gateway = get_gateway_from_config_entry(hass, config_entry) - device_registry = dr.async_get(hass) gateway_entry = device_registry.async_get_device( identifiers={(DECONZ_DOMAIN, gateway.bridgeid)} ) diff --git a/tests/components/deconz/test_init.py b/tests/components/deconz/test_init.py index f456508a6f3..58cb7129037 100644 --- a/tests/components/deconz/test_init.py +++ b/tests/components/deconz/test_init.py @@ -159,7 +159,9 @@ async def test_unload_entry_multiple_gateways_parallel( assert len(hass.data[DECONZ_DOMAIN]) == 0 -async def test_update_group_unique_id(hass: HomeAssistant) -> None: +async def test_update_group_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test successful migration of entry data.""" old_unique_id = "123" new_unique_id = "1234" @@ -174,9 +176,8 @@ async def test_update_group_unique_id(hass: HomeAssistant) -> None: }, ) - registry = er.async_get(hass) # Create entity entry to migrate to new unique ID - registry.async_get_or_create( + entity_registry.async_get_or_create( LIGHT_DOMAIN, DECONZ_DOMAIN, f"{old_unique_id}-OLD", @@ -184,7 +185,7 @@ async def test_update_group_unique_id(hass: HomeAssistant) -> None: config_entry=entry, ) # Create entity entry with new unique ID - registry.async_get_or_create( + entity_registry.async_get_or_create( LIGHT_DOMAIN, DECONZ_DOMAIN, f"{new_unique_id}-NEW", @@ -195,11 +196,19 @@ async def test_update_group_unique_id(hass: HomeAssistant) -> None: await async_update_group_unique_id(hass, entry) assert entry.data == {CONF_API_KEY: "1", CONF_HOST: "2", CONF_PORT: "3"} - assert registry.async_get(f"{LIGHT_DOMAIN}.old").unique_id == f"{new_unique_id}-OLD" - assert registry.async_get(f"{LIGHT_DOMAIN}.new").unique_id == f"{new_unique_id}-NEW" + assert ( + entity_registry.async_get(f"{LIGHT_DOMAIN}.old").unique_id + == f"{new_unique_id}-OLD" + ) + assert ( + entity_registry.async_get(f"{LIGHT_DOMAIN}.new").unique_id + == f"{new_unique_id}-NEW" + ) -async def test_update_group_unique_id_no_legacy_group_id(hass: HomeAssistant) -> None: +async def test_update_group_unique_id_no_legacy_group_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test migration doesn't trigger without old legacy group id in entry data.""" old_unique_id = "123" new_unique_id = "1234" @@ -209,9 +218,8 @@ async def test_update_group_unique_id_no_legacy_group_id(hass: HomeAssistant) -> data={}, ) - registry = er.async_get(hass) # Create entity entry to migrate to new unique ID - registry.async_get_or_create( + entity_registry.async_get_or_create( LIGHT_DOMAIN, DECONZ_DOMAIN, f"{old_unique_id}-OLD", @@ -221,4 +229,7 @@ async def test_update_group_unique_id_no_legacy_group_id(hass: HomeAssistant) -> await async_update_group_unique_id(hass, entry) - assert registry.async_get(f"{LIGHT_DOMAIN}.old").unique_id == f"{old_unique_id}-OLD" + assert ( + entity_registry.async_get(f"{LIGHT_DOMAIN}.old").unique_id + == f"{old_unique_id}-OLD" + ) diff --git a/tests/components/deconz/test_logbook.py b/tests/components/deconz/test_logbook.py index eb1d4fc7fef..4d2043923bd 100644 --- a/tests/components/deconz/test_logbook.py +++ b/tests/components/deconz/test_logbook.py @@ -27,7 +27,9 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_humanifying_deconz_alarm_event( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, ) -> None: """Test humanifying deCONZ event.""" data = { @@ -61,8 +63,6 @@ async def test_humanifying_deconz_alarm_event( with patch.dict(DECONZ_WEB_REQUEST, data): await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) - keypad_event_id = slugify(data["sensors"]["1"]["name"]) keypad_serial = serial_from_unique_id(data["sensors"]["1"]["uniqueid"]) keypad_entry = device_registry.async_get_device( @@ -112,7 +112,9 @@ async def test_humanifying_deconz_alarm_event( async def test_humanifying_deconz_event( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, ) -> None: """Test humanifying deCONZ event.""" data = { @@ -152,8 +154,6 @@ async def test_humanifying_deconz_event( with patch.dict(DECONZ_WEB_REQUEST, data): await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) - switch_event_id = slugify(data["sensors"]["1"]["name"]) switch_serial = serial_from_unique_id(data["sensors"]["1"]["uniqueid"]) switch_entry = device_registry.async_get_device( diff --git a/tests/components/deconz/test_number.py b/tests/components/deconz/test_number.py index 98c8cbaed8d..17cbc2917ec 100644 --- a/tests/components/deconz/test_number.py +++ b/tests/components/deconz/test_number.py @@ -111,17 +111,17 @@ TEST_DATA = [ async def test_number_entities( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, mock_deconz_websocket, sensor_data, expected, ) -> None: """Test successful creation of number entities.""" - ent_reg = er.async_get(hass) - dev_reg = dr.async_get(hass) # Create entity entry to migrate to new unique ID if "old_unique_id" in expected: - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( NUMBER_DOMAIN, DECONZ_DOMAIN, expected["old_unique_id"], @@ -141,14 +141,14 @@ async def test_number_entities( # Verify entity registry data - ent_reg_entry = ent_reg.async_get(expected["entity_id"]) + ent_reg_entry = entity_registry.async_get(expected["entity_id"]) assert ent_reg_entry.entity_category is expected["entity_category"] assert ent_reg_entry.unique_id == expected["unique_id"] # Verify device registry data assert ( - len(dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id)) + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) == expected["device_count"] ) diff --git a/tests/components/deconz/test_scene.py b/tests/components/deconz/test_scene.py index bde76017817..7d16f0bd513 100644 --- a/tests/components/deconz/test_scene.py +++ b/tests/components/deconz/test_scene.py @@ -57,12 +57,14 @@ TEST_DATA = [ @pytest.mark.parametrize(("raw_data", "expected"), TEST_DATA) async def test_scenes( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, raw_data, expected + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, + raw_data, + expected, ) -> None: """Test successful creation of scene entities.""" - ent_reg = er.async_get(hass) - dev_reg = dr.async_get(hass) - with patch.dict(DECONZ_WEB_REQUEST, raw_data): config_entry = await setup_deconz_integration(hass, aioclient_mock) @@ -75,14 +77,14 @@ async def test_scenes( # Verify entity registry data - ent_reg_entry = ent_reg.async_get(expected["entity_id"]) + ent_reg_entry = entity_registry.async_get(expected["entity_id"]) assert ent_reg_entry.entity_category is expected["entity_category"] assert ent_reg_entry.unique_id == expected["unique_id"] # Verify device registry data assert ( - len(dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id)) + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) == expected["device_count"] ) diff --git a/tests/components/deconz/test_select.py b/tests/components/deconz/test_select.py index fd625d78aed..7b7a9c86168 100644 --- a/tests/components/deconz/test_select.py +++ b/tests/components/deconz/test_select.py @@ -168,12 +168,14 @@ TEST_DATA = [ @pytest.mark.parametrize(("raw_data", "expected"), TEST_DATA) async def test_select( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, raw_data, expected + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, + raw_data, + expected, ) -> None: """Test successful creation of button entities.""" - ent_reg = er.async_get(hass) - dev_reg = dr.async_get(hass) - with patch.dict(DECONZ_WEB_REQUEST, raw_data): config_entry = await setup_deconz_integration(hass, aioclient_mock) @@ -186,14 +188,14 @@ async def test_select( # Verify entity registry data - ent_reg_entry = ent_reg.async_get(expected["entity_id"]) + ent_reg_entry = entity_registry.async_get(expected["entity_id"]) assert ent_reg_entry.entity_category is expected["entity_category"] assert ent_reg_entry.unique_id == expected["unique_id"] # Verify device registry data assert ( - len(dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id)) + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) == expected["device_count"] ) diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index 4d93df17ba3..38d68d135b6 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -530,6 +530,55 @@ TEST_DATA = [ "next_state": "1.3", }, ), + ( # Particulate matter -> pm2_5 + { + "capabilities": { + "measured_value": { + "max": 999, + "min": 0, + "quantity": "density", + "substance": "PM2.5", + "unit": "ug/m^3", + } + }, + "config": {"on": True, "reachable": True}, + "ep": 1, + "etag": "2a67a4b5cbcc20532c0ee75e2abac0c3", + "lastannounced": None, + "lastseen": "2023-10-29T12:59Z", + "manufacturername": "IKEA of Sweden", + "modelid": "STARKVIND Air purifier table", + "name": "STARKVIND AirPurifier", + "productid": "E2006", + "state": { + "airquality": "excellent", + "lastupdated": "2023-10-29T12:59:27.976", + "measured_value": 1, + "pm2_5": 1, + }, + "swversion": "1.1.001", + "type": "ZHAParticulateMatter", + "uniqueid": "xx:xx:xx:xx:xx:xx:xx:xx-01-042a", + }, + { + "entity_count": 1, + "device_count": 3, + "entity_id": "sensor.starkvind_airpurifier_pm25", + "unique_id": "xx:xx:xx:xx:xx:xx:xx:xx-01-042a-particulate_matter_pm2_5", + "state": "1", + "entity_category": None, + "device_class": SensorDeviceClass.PM25, + "state_class": SensorStateClass.MEASUREMENT, + "attributes": { + "friendly_name": "STARKVIND AirPurifier PM25", + "device_class": SensorDeviceClass.PM25, + "state_class": SensorStateClass.MEASUREMENT, + "unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + "websocket_event": {"state": {"measured_value": 2}}, + "next_state": "2", + }, + ), ( # Power sensor { "config": { @@ -792,18 +841,18 @@ TEST_DATA = [ @pytest.mark.parametrize(("sensor_data", "expected"), TEST_DATA) async def test_sensors( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, aioclient_mock: AiohttpClientMocker, mock_deconz_websocket, sensor_data, expected, ) -> None: """Test successful creation of sensor entities.""" - ent_reg = er.async_get(hass) - dev_reg = dr.async_get(hass) # Create entity entry to migrate to new unique ID if "old_unique_id" in expected: - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( SENSOR_DOMAIN, DECONZ_DOMAIN, expected["old_unique_id"], @@ -817,7 +866,9 @@ async def test_sensors( # Enable in entity registry if expected.get("enable_entity"): - ent_reg.async_update_entity(entity_id=expected["entity_id"], disabled_by=None) + entity_registry.async_update_entity( + entity_id=expected["entity_id"], disabled_by=None + ) await hass.async_block_till_done() async_fire_time_changed( @@ -836,16 +887,16 @@ async def test_sensors( # Verify entity registry assert ( - ent_reg.async_get(expected["entity_id"]).entity_category + entity_registry.async_get(expected["entity_id"]).entity_category is expected["entity_category"] ) - ent_reg_entry = ent_reg.async_get(expected["entity_id"]) + ent_reg_entry = entity_registry.async_get(expected["entity_id"]) assert ent_reg_entry.entity_category is expected["entity_category"] assert ent_reg_entry.unique_id == expected["unique_id"] # Verify device registry assert ( - len(dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id)) + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) == expected["device_count"] ) diff --git a/tests/components/deconz/test_services.py b/tests/components/deconz/test_services.py index 3171746716d..ade7aba2346 100644 --- a/tests/components/deconz/test_services.py +++ b/tests/components/deconz/test_services.py @@ -349,7 +349,10 @@ async def test_service_refresh_devices_trigger_no_state_update( async def test_remove_orphaned_entries_service( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test service works and also don't remove more than expected.""" data = { @@ -374,7 +377,6 @@ async def test_remove_orphaned_entries_service( with patch.dict(DECONZ_WEB_REQUEST, data): config_entry = await setup_deconz_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "123")}, @@ -391,7 +393,6 @@ async def test_remove_orphaned_entries_service( == 5 # Host, gateway, light, switch and orphan ) - entity_registry = er.async_get(hass) entity_registry.async_get_or_create( SENSOR_DOMAIN, DECONZ_DOMAIN, diff --git a/tests/components/deconz/test_switch.py b/tests/components/deconz/test_switch.py index 7c3a3498935..31555a71011 100644 --- a/tests/components/deconz/test_switch.py +++ b/tests/components/deconz/test_switch.py @@ -119,13 +119,14 @@ async def test_power_plugs( async def test_remove_legacy_on_off_output_as_light( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test that switch platform cleans up legacy light entities.""" unique_id = "00:00:00:00:00:00:00:00-00" - registry = er.async_get(hass) - switch_light_entity = registry.async_get_or_create( + switch_light_entity = entity_registry.async_get_or_create( LIGHT_DOMAIN, DECONZ_DOMAIN, unique_id ) @@ -144,6 +145,6 @@ async def test_remove_legacy_on_off_output_as_light( with patch.dict(DECONZ_WEB_REQUEST, data): await setup_deconz_integration(hass, aioclient_mock) - assert not registry.async_get("light.on_off_output_device") - assert registry.async_get("switch.on_off_output_device") + assert not entity_registry.async_get("light.on_off_output_device") + assert entity_registry.async_get("switch.on_off_output_device") assert len(hass.states.async_all()) == 1 diff --git a/tests/components/demo/test_fan.py b/tests/components/demo/test_fan.py index 58a8c99ea3c..a3f607aee76 100644 --- a/tests/components/demo/test_fan.py +++ b/tests/components/demo/test_fan.py @@ -182,7 +182,7 @@ async def test_turn_on_with_preset_mode_only( assert state.state == STATE_OFF assert state.attributes[fan.ATTR_PRESET_MODE] is None - with pytest.raises(ValueError): + with pytest.raises(fan.NotValidPresetModeError) as exc: await hass.services.async_call( fan.DOMAIN, SERVICE_TURN_ON, @@ -190,6 +190,12 @@ async def test_turn_on_with_preset_mode_only( blocking=True, ) await hass.async_block_till_done() + assert exc.value.translation_domain == fan.DOMAIN + assert exc.value.translation_key == "not_valid_preset_mode" + assert exc.value.translation_placeholders == { + "preset_mode": "invalid", + "preset_modes": "auto, smart, sleep, on", + } state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF @@ -250,7 +256,7 @@ async def test_turn_on_with_preset_mode_and_speed( assert state.attributes[fan.ATTR_PERCENTAGE] == 0 assert state.attributes[fan.ATTR_PRESET_MODE] is None - with pytest.raises(ValueError): + with pytest.raises(fan.NotValidPresetModeError) as exc: await hass.services.async_call( fan.DOMAIN, SERVICE_TURN_ON, @@ -258,6 +264,12 @@ async def test_turn_on_with_preset_mode_and_speed( blocking=True, ) await hass.async_block_till_done() + assert exc.value.translation_domain == fan.DOMAIN + assert exc.value.translation_key == "not_valid_preset_mode" + assert exc.value.translation_placeholders == { + "preset_mode": "invalid", + "preset_modes": "auto, smart, sleep, on", + } state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF @@ -343,7 +355,7 @@ async def test_set_preset_mode_invalid(hass: HomeAssistant, fan_entity_id) -> No state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF - with pytest.raises(ValueError): + with pytest.raises(fan.NotValidPresetModeError) as exc: await hass.services.async_call( fan.DOMAIN, fan.SERVICE_SET_PRESET_MODE, @@ -351,8 +363,10 @@ async def test_set_preset_mode_invalid(hass: HomeAssistant, fan_entity_id) -> No blocking=True, ) await hass.async_block_till_done() + assert exc.value.translation_domain == fan.DOMAIN + assert exc.value.translation_key == "not_valid_preset_mode" - with pytest.raises(ValueError): + with pytest.raises(fan.NotValidPresetModeError) as exc: await hass.services.async_call( fan.DOMAIN, SERVICE_TURN_ON, @@ -360,6 +374,8 @@ async def test_set_preset_mode_invalid(hass: HomeAssistant, fan_entity_id) -> No blocking=True, ) await hass.async_block_till_done() + assert exc.value.translation_domain == fan.DOMAIN + assert exc.value.translation_key == "not_valid_preset_mode" @pytest.mark.parametrize("fan_entity_id", FULL_FAN_ENTITY_IDS) diff --git a/tests/components/denonavr/test_config_flow.py b/tests/components/denonavr/test_config_flow.py index 93a6305655b..a0fb908d920 100644 --- a/tests/components/denonavr/test_config_flow.py +++ b/tests/components/denonavr/test_config_flow.py @@ -65,7 +65,8 @@ def denonavr_connect_fixture(): "homeassistant.components.denonavr.receiver.DenonAVR.receiver_type", TEST_RECEIVER_TYPE, ), patch( - "homeassistant.components.denonavr.async_setup_entry", return_value=True + "homeassistant.components.denonavr.async_setup_entry", + return_value=True, ): yield diff --git a/tests/components/derivative/test_init.py b/tests/components/derivative/test_init.py index fef13109007..eab8ca67be7 100644 --- a/tests/components/derivative/test_init.py +++ b/tests/components/derivative/test_init.py @@ -11,11 +11,11 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize("platform", ("sensor",)) async def test_setup_and_remove_config_entry( hass: HomeAssistant, + entity_registry: er.EntityRegistry, platform: str, ) -> None: """Test setting up and removing a config entry.""" input_sensor_entity_id = "sensor.input" - registry = er.async_get(hass) derivative_entity_id = f"{platform}.my_derivative" # Setup the config entry @@ -37,7 +37,7 @@ async def test_setup_and_remove_config_entry( await hass.async_block_till_done() # Check the entity is registered in the entity registry - assert registry.async_get(derivative_entity_id) is not None + assert entity_registry.async_get(derivative_entity_id) is not None # Check the platform is setup correctly state = hass.states.get(derivative_entity_id) @@ -58,4 +58,4 @@ async def test_setup_and_remove_config_entry( # Check the state and entity registry entry are removed assert hass.states.get(derivative_entity_id) is None - assert registry.async_get(derivative_entity_id) is None + assert entity_registry.async_get(derivative_entity_id) is None diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index 5ba00cabd9d..4d954fcbb43 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -348,11 +348,12 @@ async def test_suffix(hass: HomeAssistant) -> None: assert round(float(state.state), config["sensor"]["round"]) == 0.0 -async def test_device_id(hass: HomeAssistant) -> None: +async def test_device_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: """Test for source entity device for Derivative.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - source_config_entry = MockConfigEntry() source_config_entry.add_to_hass(hass) source_device_entry = device_registry.async_get_or_create( diff --git a/tests/components/devialet/__init__.py b/tests/components/devialet/__init__.py new file mode 100644 index 00000000000..28ab6229c44 --- /dev/null +++ b/tests/components/devialet/__init__.py @@ -0,0 +1,150 @@ +"""Tests for the Devialet integration.""" + +from ipaddress import ip_address + +from aiohttp import ClientError as ServerTimeoutError +from devialet.const import UrlSuffix + +from homeassistant.components import zeroconf +from homeassistant.components.devialet.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME, CONTENT_TYPE_JSON +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + +NAME = "Livingroom" +SERIAL = "L00P00000AB11" +HOST = "127.0.0.1" +CONF_INPUT = {CONF_HOST: HOST} + +CONF_DATA = { + CONF_HOST: HOST, + CONF_NAME: NAME, +} + +MOCK_CONFIG = {DOMAIN: [{CONF_HOST: HOST}]} +MOCK_USER_INPUT = {CONF_HOST: HOST} +MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( + ip_address=ip_address(HOST), + ip_addresses=[ip_address(HOST)], + hostname="PhantomISilver-L00P00000AB11.local.", + type="_devialet-http._tcp.", + name="Livingroom", + port=80, + properties={ + "_raw": { + "firmwareFamily": "DOS", + "firmwareVersion": "2.16.1.49152", + "ipControlVersion": "1", + "manufacturer": "Devialet", + "model": "Phantom I Silver", + "path": "/ipcontrol/v1", + "serialNumber": "L00P00000AB11", + }, + "firmwareFamily": "DOS", + "firmwareVersion": "2.16.1.49152", + "ipControlVersion": "1", + "manufacturer": "Devialet", + "model": "Phantom I Silver", + "path": "/ipcontrol/v1", + "serialNumber": "L00P00000AB11", + }, +) + + +def mock_unavailable(aioclient_mock: AiohttpClientMocker) -> None: + """Mock the Devialet connection for Home Assistant.""" + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_GENERAL_INFO}", exc=ServerTimeoutError + ) + + +def mock_idle(aioclient_mock: AiohttpClientMocker) -> None: + """Mock the Devialet connection for Home Assistant.""" + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_GENERAL_INFO}", + text=load_fixture("general_info.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_CURRENT_SOURCE}", + exc=ServerTimeoutError, + ) + + +def mock_playing(aioclient_mock: AiohttpClientMocker) -> None: + """Mock the Devialet connection for Home Assistant.""" + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_GENERAL_INFO}", + text=load_fixture("general_info.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_CURRENT_SOURCE}", + text=load_fixture("source_state.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_SOURCES}", + text=load_fixture("sources.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_VOLUME}", + text=load_fixture("volume.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_NIGHT_MODE}", + text=load_fixture("night_mode.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_EQUALIZER}", + text=load_fixture("equalizer.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_CURRENT_POSITION}", + text=load_fixture("current_position.json", DOMAIN), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + +async def setup_integration( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + skip_entry_setup: bool = False, + state: str = "playing", + serial: str = SERIAL, +) -> MockConfigEntry: + """Set up the Devialet integration in Home Assistant.""" + + if state == "playing": + mock_playing(aioclient_mock) + elif state == "unavailable": + mock_unavailable(aioclient_mock) + elif state == "idle": + mock_idle(aioclient_mock) + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=serial, + data=CONF_DATA, + ) + + entry.add_to_hass(hass) + + if not skip_entry_setup: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/devialet/fixtures/current_position.json b/tests/components/devialet/fixtures/current_position.json new file mode 100644 index 00000000000..2b9761cc03a --- /dev/null +++ b/tests/components/devialet/fixtures/current_position.json @@ -0,0 +1,3 @@ +{ + "position": 123102 +} diff --git a/tests/components/devialet/fixtures/equalizer.json b/tests/components/devialet/fixtures/equalizer.json new file mode 100644 index 00000000000..be9ea651d6e --- /dev/null +++ b/tests/components/devialet/fixtures/equalizer.json @@ -0,0 +1,26 @@ +{ + "availablePresets": ["custom", "flat", "voice"], + "currentEqualization": { + "high": { + "gain": 0 + }, + "low": { + "gain": 0 + } + }, + "customEqualization": { + "high": { + "gain": 0 + }, + "low": { + "gain": 0 + } + }, + "enabled": true, + "gainRange": { + "max": 6, + "min": -6, + "stepPrecision": 1 + }, + "preset": "flat" +} diff --git a/tests/components/devialet/fixtures/general_info.json b/tests/components/devialet/fixtures/general_info.json new file mode 100644 index 00000000000..6ff1a724f08 --- /dev/null +++ b/tests/components/devialet/fixtures/general_info.json @@ -0,0 +1,18 @@ +{ + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "deviceName": "Livingroom", + "firmwareFamily": "DOS", + "groupId": "12345678-901a-2b3c-def4-567g89h0i12j", + "ipControlVersion": "1", + "model": "Phantom I Silver", + "release": { + "buildType": "release", + "canonicalVersion": "2.16.1.49152", + "version": "2.16.1" + }, + "role": "FrontLeft", + "serial": "L00P00000AB11", + "standbyEntryDelay": 0, + "standbyState": "Unknown", + "systemId": "a12b345c-67d8-90e1-12f4-g5hij67890kl" +} diff --git a/tests/components/devialet/fixtures/night_mode.json b/tests/components/devialet/fixtures/night_mode.json new file mode 100644 index 00000000000..e61cc12151d --- /dev/null +++ b/tests/components/devialet/fixtures/night_mode.json @@ -0,0 +1,3 @@ +{ + "nightMode": "off" +} diff --git a/tests/components/devialet/fixtures/no_current_source.json b/tests/components/devialet/fixtures/no_current_source.json new file mode 100644 index 00000000000..ac16468597d --- /dev/null +++ b/tests/components/devialet/fixtures/no_current_source.json @@ -0,0 +1,7 @@ +{ + "error": { + "code": "NoCurrentSource", + "details": {}, + "message": "" + } +} diff --git a/tests/components/devialet/fixtures/source_state.json b/tests/components/devialet/fixtures/source_state.json new file mode 100644 index 00000000000..d389675ac98 --- /dev/null +++ b/tests/components/devialet/fixtures/source_state.json @@ -0,0 +1,20 @@ +{ + "availableOptions": ["play", "pause", "previous", "next", "seek"], + "metadata": { + "album": "1 (Remastered)", + "artist": "The Beatles", + "coverArtDataPresent": false, + "coverArtUrl": "https://i.scdn.co/image/ab67616d0000b273582d56ce20fe0146ffa0e5cf", + "duration": 425653, + "mediaType": "unknown", + "title": "Hey Jude - Remastered 2015" + }, + "muteState": "unmuted", + "peerDeviceName": "", + "playingState": "playing", + "source": { + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "sourceId": "7b0d8ed0-5650-45cd-841b-647b78730bfb", + "type": "spotifyconnect" + } +} diff --git a/tests/components/devialet/fixtures/sources.json b/tests/components/devialet/fixtures/sources.json new file mode 100644 index 00000000000..5f484314d73 --- /dev/null +++ b/tests/components/devialet/fixtures/sources.json @@ -0,0 +1,41 @@ +{ + "sources": [ + { + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "sourceId": "7b0d8ed0-5650-45cd-841b-647b78730bfb", + "type": "spotifyconnect" + }, + { + "deviceId": "9abc87d6-ef54-321d-0g9h-ijk876l54m32", + "sourceId": "12708064-01fa-4e25-a0f1-f94b3de49baa", + "streamLockAvailable": false, + "type": "optical" + }, + { + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "sourceId": "82834351-8255-4e2e-9ce2-b7d4da0aa3b0", + "streamLockAvailable": false, + "type": "optical" + }, + { + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "sourceId": "07b1bf6d-9216-4a7b-8d53-5590cee21d90", + "type": "upnp" + }, + { + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "sourceId": "1015e17d-d515-419d-a47b-4a7252bff838", + "type": "airplay2" + }, + { + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "sourceId": "88186c24-f896-4ef0-a731-a6c8f8f01908", + "type": "bluetooth" + }, + { + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "sourceId": "acfd9fe6-7e29-4c2b-b2bd-5083486a5291", + "type": "raat" + } + ] +} diff --git a/tests/components/devialet/fixtures/system_info.json b/tests/components/devialet/fixtures/system_info.json new file mode 100644 index 00000000000..f496e5557d2 --- /dev/null +++ b/tests/components/devialet/fixtures/system_info.json @@ -0,0 +1,6 @@ +{ + "availableFeatures": ["nightMode", "equalizer", "balance"], + "groupId": "12345678-901a-2b3c-def4-567g89h0i12j", + "systemId": "a12b345c-67d8-90e1-12f4-g5hij67890kl", + "systemName": "Devialet" +} diff --git a/tests/components/devialet/fixtures/volume.json b/tests/components/devialet/fixtures/volume.json new file mode 100644 index 00000000000..365d5ed776d --- /dev/null +++ b/tests/components/devialet/fixtures/volume.json @@ -0,0 +1,3 @@ +{ + "volume": 20 +} diff --git a/tests/components/devialet/test_config_flow.py b/tests/components/devialet/test_config_flow.py new file mode 100644 index 00000000000..0bacc558b74 --- /dev/null +++ b/tests/components/devialet/test_config_flow.py @@ -0,0 +1,154 @@ +"""Test the Devialet config flow.""" +from unittest.mock import patch + +from aiohttp import ClientError as HTTPClientError +from devialet.const import UrlSuffix + +from homeassistant.components.devialet.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import ( + HOST, + MOCK_USER_INPUT, + MOCK_ZEROCONF_DATA, + NAME, + mock_playing, + setup_integration, +) + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_show_user_form(hass: HomeAssistant) -> None: + """Test that the user set up form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + + assert result["step_id"] == "user" + assert result["type"] == FlowResultType.FORM + + +async def test_cannot_connect( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we show user form on connection error.""" + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_GENERAL_INFO}", exc=HTTPClientError + ) + + user_input = MOCK_USER_INPUT.copy() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_user_device_exists_abort( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we abort user flow if DirecTV receiver already configured.""" + await setup_integration(hass, aioclient_mock, skip_entry_setup=True) + + user_input = MOCK_USER_INPUT.copy() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_full_user_flow_implementation( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the full manual user flow from start to finish.""" + mock_playing(aioclient_mock) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + user_input = MOCK_USER_INPUT.copy() + with patch( + "homeassistant.components.devialet.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + + +async def test_zeroconf_devialet( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we pass Devialet devices to the discovery manager.""" + mock_playing(aioclient_mock) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=MOCK_ZEROCONF_DATA + ) + + assert result["type"] == "form" + + with patch( + "homeassistant.components.devialet.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "Livingroom" + assert result2["data"] == { + CONF_HOST: HOST, + CONF_NAME: NAME, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_async_step_confirm( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> 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"] == "form" + assert result["step_id"] == "confirm" + + aioclient_mock.get( + f"http://{HOST}{UrlSuffix.GET_GENERAL_INFO}", exc=HTTPClientError + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT.copy() + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "confirm" + assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/devialet/test_diagnostics.py b/tests/components/devialet/test_diagnostics.py new file mode 100644 index 00000000000..82600de7cf5 --- /dev/null +++ b/tests/components/devialet/test_diagnostics.py @@ -0,0 +1,40 @@ +"""Test the Devialet diagnostics.""" +import json + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import load_fixture +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test diagnostics.""" + entry = await setup_integration(hass, aioclient_mock) + + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == { + "is_available": True, + "general_info": json.loads(load_fixture("general_info.json", "devialet")), + "sources": json.loads(load_fixture("sources.json", "devialet")), + "source_state": json.loads(load_fixture("source_state.json", "devialet")), + "volume": json.loads(load_fixture("volume.json", "devialet")), + "night_mode": json.loads(load_fixture("night_mode.json", "devialet")), + "equalizer": json.loads(load_fixture("equalizer.json", "devialet")), + "source_list": [ + "Airplay", + "Bluetooth", + "Online", + "Optical left", + "Optical right", + "Raat", + "Spotify Connect", + ], + "source": "spotifyconnect", + } diff --git a/tests/components/devialet/test_init.py b/tests/components/devialet/test_init.py new file mode 100644 index 00000000000..86d383e91d8 --- /dev/null +++ b/tests/components/devialet/test_init.py @@ -0,0 +1,49 @@ +"""Test the Devialet init.""" +from homeassistant.components.devialet.const import DOMAIN +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN, MediaPlayerState +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import NAME, setup_integration + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_load_unload_config_entry( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the Devialet configuration entry loading and unloading.""" + entry = await setup_integration(hass, aioclient_mock) + + assert entry.entry_id in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.LOADED + assert entry.unique_id is not None + + state = hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}") + assert state.state == MediaPlayerState.PLAYING + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.entry_id not in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_load_unload_config_entry_when_device_unavailable( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the Devialet configuration entry loading and unloading when the device is unavailable.""" + entry = await setup_integration(hass, aioclient_mock, state="unavailable") + + assert entry.entry_id in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.LOADED + assert entry.unique_id is not None + + state = hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}") + assert state.state == "unavailable" + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.entry_id not in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/devialet/test_media_player.py b/tests/components/devialet/test_media_player.py new file mode 100644 index 00000000000..56381bf6de4 --- /dev/null +++ b/tests/components/devialet/test_media_player.py @@ -0,0 +1,312 @@ +"""Test the Devialet init.""" +from unittest.mock import PropertyMock, patch + +from devialet import DevialetApi +from devialet.const import UrlSuffix +from yarl import URL + +from homeassistant.components.devialet.const import DOMAIN +from homeassistant.components.devialet.media_player import SUPPORT_DEVIALET +from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY +from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, + ATTR_INPUT_SOURCE_LIST, + ATTR_MEDIA_ALBUM_NAME, + ATTR_MEDIA_ARTIST, + ATTR_MEDIA_DURATION, + ATTR_MEDIA_POSITION, + ATTR_MEDIA_POSITION_UPDATED_AT, + ATTR_MEDIA_TITLE, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + ATTR_SOUND_MODE, + ATTR_SOUND_MODE_LIST, + DOMAIN as MP_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + SERVICE_SELECT_SOURCE, + MediaPlayerState, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_ENTITY_PICTURE, + ATTR_SUPPORTED_FEATURES, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_SEEK, + SERVICE_MEDIA_STOP, + SERVICE_TURN_OFF, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import HOST, NAME, setup_integration + +from tests.test_util.aiohttp import AiohttpClientMocker + +SERVICE_TO_URL = { + SERVICE_MEDIA_SEEK: [UrlSuffix.SEEK], + SERVICE_MEDIA_PLAY: [UrlSuffix.PLAY], + SERVICE_MEDIA_PAUSE: [UrlSuffix.PAUSE], + SERVICE_MEDIA_STOP: [UrlSuffix.PAUSE], + SERVICE_MEDIA_PREVIOUS_TRACK: [UrlSuffix.PREVIOUS_TRACK], + SERVICE_MEDIA_NEXT_TRACK: [UrlSuffix.NEXT_TRACK], + SERVICE_TURN_OFF: [UrlSuffix.TURN_OFF], + SERVICE_VOLUME_UP: [UrlSuffix.VOLUME_UP], + SERVICE_VOLUME_DOWN: [UrlSuffix.VOLUME_DOWN], + SERVICE_VOLUME_SET: [UrlSuffix.VOLUME_SET], + SERVICE_VOLUME_MUTE: [UrlSuffix.MUTE, UrlSuffix.UNMUTE], + SERVICE_SELECT_SOUND_MODE: [UrlSuffix.EQUALIZER, UrlSuffix.NIGHT_MODE], + SERVICE_SELECT_SOURCE: [ + str(UrlSuffix.SELECT_SOURCE).replace( + "%SOURCE_ID%", "82834351-8255-4e2e-9ce2-b7d4da0aa3b0" + ), + str(UrlSuffix.SELECT_SOURCE).replace( + "%SOURCE_ID%", "07b1bf6d-9216-4a7b-8d53-5590cee21d90" + ), + ], +} + +SERVICE_TO_DATA = { + SERVICE_MEDIA_SEEK: [{"seek_position": 321}], + SERVICE_MEDIA_PLAY: [{}], + SERVICE_MEDIA_PAUSE: [{}], + SERVICE_MEDIA_STOP: [{}], + SERVICE_MEDIA_PREVIOUS_TRACK: [{}], + SERVICE_MEDIA_NEXT_TRACK: [{}], + SERVICE_TURN_OFF: [{}], + SERVICE_VOLUME_UP: [{}], + SERVICE_VOLUME_DOWN: [{}], + SERVICE_VOLUME_SET: [{ATTR_MEDIA_VOLUME_LEVEL: 0.5}], + SERVICE_VOLUME_MUTE: [ + {ATTR_MEDIA_VOLUME_MUTED: True}, + {ATTR_MEDIA_VOLUME_MUTED: False}, + ], + SERVICE_SELECT_SOUND_MODE: [ + {ATTR_SOUND_MODE: "Night mode"}, + {ATTR_SOUND_MODE: "Flat"}, + ], + SERVICE_SELECT_SOURCE: [ + {ATTR_INPUT_SOURCE: "Optical left"}, + {ATTR_INPUT_SOURCE: "Online"}, + ], +} + + +async def test_media_player_playing( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the Devialet configuration entry loading and unloading.""" + await async_setup_component(hass, "homeassistant", {}) + entry = await setup_integration(hass, aioclient_mock) + + assert entry.entry_id in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + "homeassistant", + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: [f"{MP_DOMAIN}.{NAME.lower()}"]}, + blocking=True, + ) + + state = hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}") + assert state.state == MediaPlayerState.PLAYING + assert state.name == NAME + assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.2 + assert state.attributes[ATTR_MEDIA_VOLUME_MUTED] is False + assert state.attributes[ATTR_INPUT_SOURCE_LIST] is not None + assert state.attributes[ATTR_SOUND_MODE_LIST] is not None + assert state.attributes[ATTR_MEDIA_ARTIST] == "The Beatles" + assert state.attributes[ATTR_MEDIA_ALBUM_NAME] == "1 (Remastered)" + assert state.attributes[ATTR_MEDIA_TITLE] == "Hey Jude - Remastered 2015" + assert state.attributes[ATTR_ENTITY_PICTURE] is not None + assert state.attributes[ATTR_MEDIA_DURATION] == 425653 + assert state.attributes[ATTR_MEDIA_POSITION] == 123102 + assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] is not None + assert state.attributes[ATTR_SUPPORTED_FEATURES] is not None + assert state.attributes[ATTR_INPUT_SOURCE] is not None + assert state.attributes[ATTR_SOUND_MODE] is not None + + with patch( + "homeassistant.components.devialet.DevialetApi.playing_state", + new_callable=PropertyMock, + ) as mock: + mock.return_value = MediaPlayerState.PAUSED + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + assert ( + hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}").state + == MediaPlayerState.PAUSED + ) + + with patch( + "homeassistant.components.devialet.DevialetApi.playing_state", + new_callable=PropertyMock, + ) as mock: + mock.return_value = MediaPlayerState.ON + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + assert ( + hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}").state == MediaPlayerState.ON + ) + + with patch.object(DevialetApi, "equalizer", new_callable=PropertyMock) as mock: + mock.return_value = None + + with patch.object(DevialetApi, "night_mode", new_callable=PropertyMock) as mock: + mock.return_value = True + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + assert ( + hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}").attributes[ + ATTR_SOUND_MODE + ] + == "Night mode" + ) + + with patch.object(DevialetApi, "equalizer", new_callable=PropertyMock) as mock: + mock.return_value = "unexpected_value" + + with patch.object(DevialetApi, "night_mode", new_callable=PropertyMock) as mock: + mock.return_value = False + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + assert ( + ATTR_SOUND_MODE + not in hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}").attributes + ) + + with patch.object(DevialetApi, "equalizer", new_callable=PropertyMock) as mock: + mock.return_value = None + + with patch.object(DevialetApi, "night_mode", new_callable=PropertyMock) as mock: + mock.return_value = None + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + assert ( + ATTR_SOUND_MODE + not in hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}").attributes + ) + + with patch.object( + DevialetApi, "available_options", new_callable=PropertyMock + ) as mock: + mock.return_value = None + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + assert ( + hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}").attributes[ + ATTR_SUPPORTED_FEATURES + ] + == SUPPORT_DEVIALET + ) + + with patch.object(DevialetApi, "source", new_callable=PropertyMock) as mock: + mock.return_value = "someSource" + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + assert ( + ATTR_INPUT_SOURCE + not in hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}").attributes + ) + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.entry_id not in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_media_player_offline( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the Devialet configuration entry loading and unloading.""" + entry = await setup_integration(hass, aioclient_mock, state=STATE_UNAVAILABLE) + + assert entry.entry_id in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.LOADED + + state = hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}") + assert state.state == STATE_UNAVAILABLE + assert state.name == NAME + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.entry_id not in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_media_player_without_serial( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the Devialet configuration entry loading and unloading.""" + entry = await setup_integration(hass, aioclient_mock, serial=None) + + assert entry.entry_id in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.LOADED + assert entry.unique_id is None + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.entry_id not in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_media_player_services( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the Devialet services.""" + entry = await setup_integration( + hass, aioclient_mock, state=MediaPlayerState.PLAYING + ) + + assert entry.entry_id in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.LOADED + + target = {ATTR_ENTITY_ID: hass.states.get(f"{MP_DOMAIN}.{NAME}").entity_id} + + for i, (service, urls) in enumerate(SERVICE_TO_URL.items()): + for url in urls: + aioclient_mock.post(f"http://{HOST}{url}") + + for data_set in list(SERVICE_TO_DATA.values())[i]: + service_data = target.copy() + service_data.update(data_set) + + await hass.services.async_call( + MP_DOMAIN, + service, + service_data=service_data, + blocking=True, + ) + await hass.async_block_till_done() + + for url in urls: + call_available = False + for item in aioclient_mock.mock_calls: + if item[0] == "POST" and item[1] == URL(f"http://{HOST}{url}"): + call_available = True + break + + assert call_available + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.entry_id not in hass.data[DOMAIN] + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/device_sun_light_trigger/test_init.py b/tests/components/device_sun_light_trigger/test_init.py index 6b563f1cb5f..724ae612f0d 100644 --- a/tests/components/device_sun_light_trigger/test_init.py +++ b/tests/components/device_sun_light_trigger/test_init.py @@ -182,7 +182,16 @@ async def test_lights_turn_on_when_coming_home_after_sun_set_person( assert await async_setup_component(hass, "group", {}) await hass.async_block_till_done() - await group.Group.async_create_group(hass, "person_me", ["person.me"]) + await group.Group.async_create_group( + hass, + "person_me", + created_by_service=False, + entity_ids=["person.me"], + icon=None, + mode=None, + object_id=None, + order=None, + ) assert await async_setup_component( hass, diff --git a/tests/components/device_tracker/test_config_entry.py b/tests/components/device_tracker/test_config_entry.py index f9c259a00f4..e55a9b5b6b2 100644 --- a/tests/components/device_tracker/test_config_entry.py +++ b/tests/components/device_tracker/test_config_entry.py @@ -25,33 +25,34 @@ def test_tracker_entity() -> None: async def test_cleanup_legacy( - hass: HomeAssistant, enable_custom_integrations: None + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + enable_custom_integrations: None, ) -> None: """Test we clean up devices created by old device tracker.""" - dev_reg = dr.async_get(hass) - ent_reg = er.async_get(hass) config_entry = MockConfigEntry(domain="test") config_entry.add_to_hass(hass) - device1 = dev_reg.async_get_or_create( + device1 = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "device1")} ) - device2 = dev_reg.async_get_or_create( + device2 = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "device2")} ) - device3 = dev_reg.async_get_or_create( + device3 = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "device3")} ) # Device with light + device tracker entity - entity1a = ent_reg.async_get_or_create( + entity1a = entity_registry.async_get_or_create( DOMAIN, "test", "entity1a-unique", config_entry=config_entry, device_id=device1.id, ) - entity1b = ent_reg.async_get_or_create( + entity1b = entity_registry.async_get_or_create( "light", "test", "entity1b-unique", @@ -59,7 +60,7 @@ async def test_cleanup_legacy( device_id=device1.id, ) # Just device tracker entity - entity2a = ent_reg.async_get_or_create( + entity2a = entity_registry.async_get_or_create( DOMAIN, "test", "entity2a-unique", @@ -67,7 +68,7 @@ async def test_cleanup_legacy( device_id=device2.id, ) # Device with no device tracker entities - entity3a = ent_reg.async_get_or_create( + entity3a = entity_registry.async_get_or_create( "light", "test", "entity3a-unique", @@ -75,14 +76,14 @@ async def test_cleanup_legacy( device_id=device3.id, ) # Device tracker but no device - entity4a = ent_reg.async_get_or_create( + entity4a = entity_registry.async_get_or_create( DOMAIN, "test", "entity4a-unique", config_entry=config_entry, ) # Completely different entity - entity5a = ent_reg.async_get_or_create( + entity5a = entity_registry.async_get_or_create( "light", "test", "entity4a-unique", @@ -93,25 +94,26 @@ async def test_cleanup_legacy( await hass.async_block_till_done() for entity in (entity1a, entity1b, entity3a, entity4a, entity5a): - assert ent_reg.async_get(entity.entity_id) is not None + assert entity_registry.async_get(entity.entity_id) is not None # We've removed device so device ID cleared - assert ent_reg.async_get(entity2a.entity_id).device_id is None + assert entity_registry.async_get(entity2a.entity_id).device_id is None # Removed because only had device tracker entity - assert dev_reg.async_get(device2.id) is None + assert device_registry.async_get(device2.id) is None -async def test_register_mac(hass: HomeAssistant) -> None: +async def test_register_mac( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: """Test registering a mac.""" - dev_reg = dr.async_get(hass) - ent_reg = er.async_get(hass) - config_entry = MockConfigEntry(domain="test") config_entry.add_to_hass(hass) mac1 = "12:34:56:AB:CD:EF" - entity_entry_1 = ent_reg.async_get_or_create( + entity_entry_1 = entity_registry.async_get_or_create( "device_tracker", "test", mac1 + "yo1", @@ -122,29 +124,30 @@ async def test_register_mac(hass: HomeAssistant) -> None: ce._async_register_mac(hass, "test", mac1, mac1 + "yo1") - dev_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, mac1)}, ) await hass.async_block_till_done() - entity_entry_1 = ent_reg.async_get(entity_entry_1.entity_id) + entity_entry_1 = entity_registry.async_get(entity_entry_1.entity_id) assert entity_entry_1.disabled_by is None -async def test_register_mac_ignored(hass: HomeAssistant) -> None: +async def test_register_mac_ignored( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: """Test ignoring registering a mac.""" - dev_reg = dr.async_get(hass) - ent_reg = er.async_get(hass) - config_entry = MockConfigEntry(domain="test", pref_disable_new_entities=True) config_entry.add_to_hass(hass) mac1 = "12:34:56:AB:CD:EF" - entity_entry_1 = ent_reg.async_get_or_create( + entity_entry_1 = entity_registry.async_get_or_create( "device_tracker", "test", mac1 + "yo1", @@ -155,14 +158,14 @@ async def test_register_mac_ignored(hass: HomeAssistant) -> None: ce._async_register_mac(hass, "test", mac1, mac1 + "yo1") - dev_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, mac1)}, ) await hass.async_block_till_done() - entity_entry_1 = ent_reg.async_get(entity_entry_1.entity_id) + entity_entry_1 = entity_registry.async_get(entity_entry_1.entity_id) assert entity_entry_1.disabled_by == er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/device_tracker/test_entities.py b/tests/components/device_tracker/test_entities.py index 960f9c18b08..45f1b21c89a 100644 --- a/tests/components/device_tracker/test_entities.py +++ b/tests/components/device_tracker/test_entities.py @@ -21,13 +21,15 @@ from tests.common import MockConfigEntry async def test_scanner_entity_device_tracker( - hass: HomeAssistant, enable_custom_integrations: None + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + enable_custom_integrations: None, ) -> None: """Test ScannerEntity based device tracker.""" # Make device tied to other integration so device tracker entities get enabled other_config_entry = MockConfigEntry(domain="not_fake_integration") other_config_entry.add_to_hass(hass) - dr.async_get(hass).async_get_or_create( + device_registry.async_get_or_create( name="Device from other integration", config_entry_id=other_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "ad:de:ef:be:ed:fe")}, diff --git a/tests/components/device_tracker/test_legacy.py b/tests/components/device_tracker/test_legacy.py new file mode 100644 index 00000000000..d7a2f33c23b --- /dev/null +++ b/tests/components/device_tracker/test_legacy.py @@ -0,0 +1,44 @@ +"""Tests for the legacy device tracker component.""" +from unittest.mock import mock_open, patch + +from homeassistant.components.device_tracker import legacy +from homeassistant.core import HomeAssistant +from homeassistant.util.yaml import dump + +from tests.common import patch_yaml_files + + +def test_remove_device_from_config(hass: HomeAssistant): + """Test the removal of a device from a config.""" + yaml_devices = { + "test": { + "hide_if_away": True, + "mac": "00:11:22:33:44:55", + "name": "Test name", + "picture": "/local/test.png", + "track": True, + }, + "test2": { + "hide_if_away": True, + "mac": "00:ab:cd:33:44:55", + "name": "Test2", + "picture": "/local/test2.png", + "track": True, + }, + } + mopen = mock_open() + + files = {legacy.YAML_DEVICES: dump(yaml_devices)} + with patch_yaml_files(files, True), patch( + "homeassistant.components.device_tracker.legacy.open", mopen + ): + legacy.remove_device_from_config(hass, "test") + + mopen().write.assert_called_once_with( + "test2:\n" + " hide_if_away: true\n" + " mac: 00:ab:cd:33:44:55\n" + " name: Test2\n" + " picture: /local/test2.png\n" + " track: true\n" + ) diff --git a/tests/components/devolo_home_control/test_init.py b/tests/components/devolo_home_control/test_init.py index 29572f2ece4..cb4c87aebdc 100644 --- a/tests/components/devolo_home_control/test_init.py +++ b/tests/components/devolo_home_control/test_init.py @@ -65,6 +65,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: async def test_remove_device( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, ) -> None: """Test removing a device.""" assert await async_setup_component(hass, "config", {}) @@ -77,7 +78,6 @@ async def test_remove_device( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "Test")}) assert device_entry diff --git a/tests/components/devolo_home_network/snapshots/test_init.ambr b/tests/components/devolo_home_network/snapshots/test_init.ambr index f2c27183945..6ba4292c5de 100644 --- a/tests/components/devolo_home_network/snapshots/test_init.ambr +++ b/tests/components/devolo_home_network/snapshots/test_init.ambr @@ -5,6 +5,10 @@ 'config_entries': , 'configuration_url': 'http://192.0.2.1', 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), }), 'disabled_by': None, 'entry_type': None, diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 076138080cc..5013568ad39 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -151,8 +151,11 @@ async def _async_get_handle_dhcp_packet(hass, integration_matchers): with patch( "homeassistant.components.dhcp._verify_l2socket_setup", ), patch( - "scapy.arch.common.compile_filter" - ), patch("scapy.sendrecv.AsyncSniffer", _mock_sniffer): + "scapy.arch.common.compile_filter", + ), patch( + "scapy.sendrecv.AsyncSniffer", + _mock_sniffer, + ): await dhcp_watcher.async_start() return async_handle_dhcp_packet @@ -213,7 +216,9 @@ async def test_dhcp_renewal_match_hostname_and_macaddress(hass: HomeAssistant) - ) -async def test_registered_devices(hass: HomeAssistant) -> None: +async def test_registered_devices( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test discovery flows are created for registered devices.""" integration_matchers = [ {"domain": "not-matching", "registered_devices": True}, @@ -222,10 +227,9 @@ async def test_registered_devices(hass: HomeAssistant) -> None: packet = Ether(RAW_DHCP_RENEWAL) - registry = dr.async_get(hass) config_entry = MockConfigEntry(domain="mock-domain", data={}) config_entry.add_to_hass(hass) - registry.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "50147903852c")}, name="name", @@ -233,7 +237,7 @@ async def test_registered_devices(hass: HomeAssistant) -> None: # Not enabled should not get flows config_entry2 = MockConfigEntry(domain="mock-domain-2", data={}) config_entry2.add_to_hass(hass) - registry.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=config_entry2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "50147903852c")}, name="name", diff --git a/tests/components/directv/test_media_player.py b/tests/components/directv/test_media_player.py index 8d11dc6c9d0..5dc76a2170e 100644 --- a/tests/components/directv/test_media_player.py +++ b/tests/components/directv/test_media_player.py @@ -142,13 +142,13 @@ async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) - async def test_unique_id( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test unique id.""" await setup_integration(hass, aioclient_mock) - entity_registry = er.async_get(hass) - main = entity_registry.async_get(MAIN_ENTITY_ID) assert main.original_device_class == MediaPlayerDeviceClass.RECEIVER assert main.unique_id == "028877455858" diff --git a/tests/components/directv/test_remote.py b/tests/components/directv/test_remote.py index 7a674fefa8c..9d326903933 100644 --- a/tests/components/directv/test_remote.py +++ b/tests/components/directv/test_remote.py @@ -29,13 +29,13 @@ async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) - async def test_unique_id( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test unique id.""" await setup_integration(hass, aioclient_mock) - entity_registry = er.async_get(hass) - main = entity_registry.async_get(MAIN_ENTITY_ID) assert main.unique_id == "028877455858" diff --git a/tests/components/discovergy/conftest.py b/tests/components/discovergy/conftest.py index ea0fe84852f..819a1cbb72a 100644 --- a/tests/components/discovergy/conftest.py +++ b/tests/components/discovergy/conftest.py @@ -1,33 +1,61 @@ """Fixtures for Discovergy integration tests.""" -from unittest.mock import AsyncMock, Mock, patch +from collections.abc import Generator +from unittest.mock import AsyncMock, patch +from pydiscovergy.models import Reading import pytest from homeassistant.components.discovergy import DOMAIN from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -from tests.components.discovergy.const import GET_METERS +from tests.components.discovergy.const import GET_METERS, LAST_READING, LAST_READING_GAS -@pytest.fixture -def mock_meters() -> Mock: - """Patch libraries.""" - with patch("pydiscovergy.Discovergy.meters") as discovergy: - discovergy.side_effect = AsyncMock(return_value=GET_METERS) - yield discovergy +def _meter_last_reading(meter_id: str) -> Reading: + """Side effect function for Discovergy mock.""" + return ( + LAST_READING_GAS + if meter_id == "d81a652fe0824f9a9d336016587d3b9d" + else LAST_READING + ) -@pytest.fixture +@pytest.fixture(name="discovergy") +def mock_discovergy() -> Generator[AsyncMock, None, None]: + """Mock the pydiscovergy client.""" + with patch( + "homeassistant.components.discovergy.Discovergy", + autospec=True, + ) as mock_discovergy, patch( + "homeassistant.components.discovergy.config_flow.Discovergy", + new=mock_discovergy, + ): + mock = mock_discovergy.return_value + mock.meters.return_value = GET_METERS + mock.meter_last_reading.side_effect = _meter_last_reading + yield mock + + +@pytest.fixture(name="config_entry") async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Return a MockConfigEntry for testing.""" - entry = MockConfigEntry( + return MockConfigEntry( domain=DOMAIN, title="user@example.org", unique_id="user@example.org", data={CONF_EMAIL: "user@example.org", CONF_PASSWORD: "supersecretpassword"}, ) - entry.add_to_hass(hass) - return entry + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry, discovergy: AsyncMock +) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() diff --git a/tests/components/discovergy/const.py b/tests/components/discovergy/const.py index 5c233d50ba8..6c5428741af 100644 --- a/tests/components/discovergy/const.py +++ b/tests/components/discovergy/const.py @@ -30,6 +30,32 @@ GET_METERS = [ "last_measurement_time": 1678430543742, }, ), + Meter( + meter_id="d81a652fe0824f9a9d336016587d3b9d", + serial_number="def456", + full_serial_number="def456", + type="PIP", + measurement_type="GAS", + load_profile_type="SLP", + location=Location( + zip=12345, + city="Testhause", + street="Teststraße", + street_number="1", + country="Germany", + ), + additional={ + "manufacturer_id": "TST", + "printed_full_serial_number": "def456", + "administration_number": "12345", + "scaling_factor": 1, + "current_scaling_factor": 1, + "voltage_scaling_factor": 1, + "internal_meters": 1, + "first_measurement_time": 1517569090926, + "last_measurement_time": 1678430543742, + }, + ), ] LAST_READING = Reading( @@ -50,3 +76,8 @@ LAST_READING = Reading( "voltage3": 239000.0, }, ) + +LAST_READING_GAS = Reading( + time=datetime.datetime(2023, 3, 10, 7, 32, 6, 702000), + values={"actualityDuration": 52000.0, "storageNumber": 0.0, "volume": 21064800.0}, +) diff --git a/tests/components/discovergy/snapshots/test_diagnostics.ambr b/tests/components/discovergy/snapshots/test_diagnostics.ambr index d02f57c7540..2a7dd6903af 100644 --- a/tests/components/discovergy/snapshots/test_diagnostics.ambr +++ b/tests/components/discovergy/snapshots/test_diagnostics.ambr @@ -22,8 +22,36 @@ 'serial_number': '**REDACTED**', 'type': 'TST', }), + dict({ + 'additional': dict({ + 'administration_number': '**REDACTED**', + 'current_scaling_factor': 1, + 'first_measurement_time': 1517569090926, + 'internal_meters': 1, + 'last_measurement_time': 1678430543742, + 'manufacturer_id': 'TST', + 'printed_full_serial_number': '**REDACTED**', + 'scaling_factor': 1, + 'voltage_scaling_factor': 1, + }), + 'full_serial_number': '**REDACTED**', + 'load_profile_type': 'SLP', + 'location': '**REDACTED**', + 'measurement_type': 'GAS', + 'meter_id': 'd81a652fe0824f9a9d336016587d3b9d', + 'serial_number': '**REDACTED**', + 'type': 'PIP', + }), ]), 'readings': dict({ + 'd81a652fe0824f9a9d336016587d3b9d': dict({ + 'time': '2023-03-10T07:32:06.702000', + 'values': dict({ + 'actualityDuration': 52000.0, + 'storageNumber': 0.0, + 'volume': 21064800.0, + }), + }), 'f8d610b7a8cc4e73939fa33b990ded54': dict({ 'time': '2023-03-10T07:32:06.702000', 'values': dict({ diff --git a/tests/components/discovergy/snapshots/test_sensor.ambr b/tests/components/discovergy/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..981d1119a93 --- /dev/null +++ b/tests/components/discovergy/snapshots/test_sensor.ambr @@ -0,0 +1,222 @@ +# serializer version: 1 +# name: test_sensor[electricity last transmitted] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.electricity_teststrasse_1_last_transmitted', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last transmitted', + 'platform': 'discovergy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_transmitted', + 'unique_id': 'abc123-last_transmitted', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[electricity last transmitted].1 + None +# --- +# name: test_sensor[electricity total consumption] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.electricity_teststrasse_1_total_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 4, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total consumption', + 'platform': 'discovergy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_consumption', + 'unique_id': 'abc123-energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[electricity total consumption].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Electricity Teststraße 1 Total consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.electricity_teststrasse_1_total_consumption', + 'last_changed': , + 'last_updated': , + 'state': '11934.8699715', + }) +# --- +# name: test_sensor[electricity total power] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.electricity_teststrasse_1_total_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total power', + 'platform': 'discovergy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_power', + 'unique_id': 'abc123-power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[electricity total power].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Electricity Teststraße 1 Total power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.electricity_teststrasse_1_total_power', + 'last_changed': , + 'last_updated': , + 'state': '531.75', + }) +# --- +# name: test_sensor[gas last transmitted] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gas_teststrasse_1_last_transmitted', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last transmitted', + 'platform': 'discovergy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_transmitted', + 'unique_id': 'def456-last_transmitted', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[gas last transmitted].1 + None +# --- +# name: test_sensor[gas total consumption] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gas_teststrasse_1_total_gas_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 4, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total gas consumption', + 'platform': 'discovergy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_gas_consumption', + 'unique_id': 'def456-volume', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[gas total consumption].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'gas', + 'friendly_name': 'Gas Teststraße 1 Total gas consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gas_teststrasse_1_total_gas_consumption', + 'last_changed': , + 'last_updated': , + 'state': '21064.8', + }) +# --- diff --git a/tests/components/discovergy/test_config_flow.py b/tests/components/discovergy/test_config_flow.py index 08e9df06978..7c257f814c4 100644 --- a/tests/components/discovergy/test_config_flow.py +++ b/tests/components/discovergy/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Discovergy config flow.""" -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, patch from pydiscovergy.error import DiscovergyClientError, HTTPError, InvalidLogin import pytest @@ -11,10 +11,9 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -from tests.components.discovergy.const import GET_METERS -async def test_form(hass: HomeAssistant, mock_meters: Mock) -> None: +async def test_form(hass: HomeAssistant, discovergy: AsyncMock) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -45,12 +44,14 @@ async def test_form(hass: HomeAssistant, mock_meters: Mock) -> None: async def test_reauth( - hass: HomeAssistant, mock_meters: Mock, mock_config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry, discovergy: AsyncMock ) -> None: """Test reauth flow.""" + config_entry.add_to_hass(hass) + init_result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": SOURCE_REAUTH, "unique_id": mock_config_entry.unique_id}, + context={"source": SOURCE_REAUTH, "unique_id": config_entry.unique_id}, data=None, ) @@ -84,35 +85,34 @@ async def test_reauth( (Exception, "unknown"), ], ) -async def test_form_fail(hass: HomeAssistant, error: Exception, message: str) -> None: +async def test_form_fail( + hass: HomeAssistant, discovergy: AsyncMock, error: Exception, message: str +) -> None: """Test to handle exceptions.""" + discovergy.meters.side_effect = error + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) - with patch( - "pydiscovergy.Discovergy.meters", - side_effect=error, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ - CONF_EMAIL: "test@example.com", - CONF_PASSWORD: "test-password", - }, - ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": message} - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": message} + # reset and test for success + discovergy.meters.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) - with patch("pydiscovergy.Discovergy.meters", return_value=GET_METERS): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "test@example.com", - CONF_PASSWORD: "test-password", - }, - ) - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == "test@example.com" - assert "errors" not in result + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "test@example.com" + assert "errors" not in result diff --git a/tests/components/discovergy/test_diagnostics.py b/tests/components/discovergy/test_diagnostics.py index d7565e3f0c4..f2db5fb854d 100644 --- a/tests/components/discovergy/test_diagnostics.py +++ b/tests/components/discovergy/test_diagnostics.py @@ -1,31 +1,22 @@ """Test Discovergy diagnostics.""" -from unittest.mock import patch - +import pytest from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry -from tests.components.discovergy.const import GET_METERS, LAST_READING from tests.typing import ClientSessionGenerator +@pytest.mark.usefixtures("setup_integration") async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - mock_config_entry: MockConfigEntry, + config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - with patch("pydiscovergy.Discovergy.meters", return_value=GET_METERS), patch( - "pydiscovergy.Discovergy.meter_last_reading", return_value=LAST_READING - ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - result = await get_diagnostics_for_config_entry( - hass, hass_client, mock_config_entry - ) + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) assert result == snapshot diff --git a/tests/components/discovergy/test_init.py b/tests/components/discovergy/test_init.py new file mode 100644 index 00000000000..ac8f79540f5 --- /dev/null +++ b/tests/components/discovergy/test_init.py @@ -0,0 +1,62 @@ +"""Test Discovergy component setup.""" +from unittest.mock import AsyncMock + +from pydiscovergy.error import DiscovergyClientError, HTTPError, InvalidLogin +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("discovergy") +async def test_config_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test for setup success.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize( + ("error", "expected_state"), + [ + (InvalidLogin, ConfigEntryState.SETUP_ERROR), + (HTTPError, ConfigEntryState.SETUP_RETRY), + (DiscovergyClientError, ConfigEntryState.SETUP_RETRY), + (Exception, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_config_not_ready( + hass: HomeAssistant, + config_entry: MockConfigEntry, + discovergy: AsyncMock, + error: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test for setup failure.""" + config_entry.add_to_hass(hass) + + discovergy.meters.side_effect = error + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is expected_state + + +@pytest.mark.usefixtures("setup_integration") +async def test_reload_config_entry( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test config entry reload.""" + new_data = {"email": "abc@example.com", "password": "password"} + + assert config_entry.state is ConfigEntryState.LOADED + + assert hass.config_entries.async_update_entry(config_entry, data=new_data) + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.data == new_data diff --git a/tests/components/discovergy/test_sensor.py b/tests/components/discovergy/test_sensor.py new file mode 100644 index 00000000000..aba8229acf5 --- /dev/null +++ b/tests/components/discovergy/test_sensor.py @@ -0,0 +1,75 @@ +"""Tests Discovergy sensor component.""" +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from pydiscovergy.error import DiscovergyClientError, HTTPError, InvalidLogin +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + + +@pytest.mark.parametrize( + "state_name", + [ + "sensor.electricity_teststrasse_1_total_consumption", + "sensor.electricity_teststrasse_1_total_power", + "sensor.electricity_teststrasse_1_last_transmitted", + "sensor.gas_teststrasse_1_total_gas_consumption", + "sensor.gas_teststrasse_1_last_transmitted", + ], + ids=[ + "electricity total consumption", + "electricity total power", + "electricity last transmitted", + "gas total consumption", + "gas last transmitted", + ], +) +@pytest.mark.usefixtures("setup_integration") +async def test_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + state_name: str, + snapshot: SnapshotAssertion, +) -> None: + """Test sensor setup and update.""" + + entry = entity_registry.async_get(state_name) + assert entry == snapshot + + state = hass.states.get(state_name) + assert state == snapshot + + +@pytest.mark.parametrize( + "error", + [ + InvalidLogin, + HTTPError, + DiscovergyClientError, + Exception, + ], +) +@pytest.mark.usefixtures("setup_integration") +async def test_sensor_update_fail( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + discovergy: AsyncMock, + error: Exception, +) -> None: + """Test sensor errors.""" + state = hass.states.get("sensor.electricity_teststrasse_1_total_consumption") + assert state + assert state.state == "11934.8699715" + + discovergy.meter_last_reading.side_effect = error + + freezer.tick(timedelta(minutes=1)) + await hass.async_block_till_done() + + state = hass.states.get("sensor.electricity_teststrasse_1_total_consumption") + assert state + assert state.state == "unavailable" diff --git a/tests/components/dlink/test_init.py b/tests/components/dlink/test_init.py index dbd4cef0139..4725d0cd3e8 100644 --- a/tests/components/dlink/test_init.py +++ b/tests/components/dlink/test_init.py @@ -59,13 +59,14 @@ async def test_async_setup_entry_not_ready( async def test_device_info( - hass: HomeAssistant, setup_integration: ComponentSetup + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + setup_integration: ComponentSetup, ) -> None: """Test device info.""" await setup_integration() entry = hass.config_entries.async_entries(DOMAIN)[0] - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) assert device.connections == {("mac", "aa:bb:cc:dd:ee:ff")} diff --git a/tests/components/dlna_dmr/conftest.py b/tests/components/dlna_dmr/conftest.py index 81225173d51..9e9bcbf3056 100644 --- a/tests/components/dlna_dmr/conftest.py +++ b/tests/components/dlna_dmr/conftest.py @@ -74,8 +74,8 @@ def domain_data_mock(hass: HomeAssistant) -> Iterable[Mock]: seal(upnp_device) domain_data.upnp_factory.async_create_device.return_value = upnp_device - with patch.dict(hass.data, {DLNA_DOMAIN: domain_data}): - yield domain_data + hass.data[DLNA_DOMAIN] = domain_data + return domain_data @pytest.fixture @@ -129,6 +129,7 @@ def dmr_device_mock(domain_data_mock: Mock) -> Iterable[Mock]: device.manufacturer = "device_manufacturer" device.model_name = "device_model_name" device.name = "device_name" + device.preset_names = ["preset1", "preset2"] yield device diff --git a/tests/components/dlna_dmr/test_config_flow.py b/tests/components/dlna_dmr/test_config_flow.py index be49a6ca257..d9b1d60708b 100644 --- a/tests/components/dlna_dmr/test_config_flow.py +++ b/tests/components/dlna_dmr/test_config_flow.py @@ -97,6 +97,15 @@ def mock_get_mac_address() -> Iterable[Mock]: yield gma_mock +@pytest.fixture(autouse=True) +def mock_setup_entry() -> Iterable[Mock]: + """Mock async_setup_entry.""" + with patch( + "homeassistant.components.dlna_dmr.async_setup_entry", return_value=True + ) as setup_entry_mock: + yield setup_entry_mock + + async def test_user_flow_undiscovered_manual(hass: HomeAssistant) -> None: """Test user-init'd flow, no discovered devices, user entering a valid URL.""" result = await hass.config_entries.flow.async_init( @@ -120,9 +129,6 @@ async def test_user_flow_undiscovered_manual(hass: HomeAssistant) -> None: } assert result["options"] == {CONF_POLL_AVAILABILITY: True} - # Wait for platform to be fully setup - await hass.async_block_till_done() - async def test_user_flow_discovered_manual( hass: HomeAssistant, ssdp_scanner_mock: Mock @@ -163,9 +169,6 @@ async def test_user_flow_discovered_manual( } assert result["options"] == {CONF_POLL_AVAILABILITY: True} - # Wait for platform to be fully setup - await hass.async_block_till_done() - async def test_user_flow_selected(hass: HomeAssistant, ssdp_scanner_mock: Mock) -> None: """Test user-init'd flow, user selects discovered device.""" @@ -196,8 +199,6 @@ async def test_user_flow_selected(hass: HomeAssistant, ssdp_scanner_mock: Mock) } assert result["options"] == {} - await hass.async_block_till_done() - async def test_user_flow_uncontactable( hass: HomeAssistant, domain_data_mock: Mock @@ -260,9 +261,6 @@ async def test_user_flow_embedded_st( } assert result["options"] == {CONF_POLL_AVAILABILITY: True} - # Wait for platform to be fully setup - await hass.async_block_till_done() - async def test_user_flow_wrong_st(hass: HomeAssistant, domain_data_mock: Mock) -> None: """Test user-init'd config flow with user entering a URL for the wrong device.""" @@ -717,9 +715,6 @@ async def test_unignore_flow(hass: HomeAssistant, ssdp_scanner_mock: Mock) -> No } assert result["options"] == {} - # Wait for platform to be fully setup - await hass.async_block_till_done() - async def test_unignore_flow_offline( hass: HomeAssistant, ssdp_scanner_mock: Mock diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py index f8413e8f620..51128b161fb 100644 --- a/tests/components/dlna_dmr/test_media_player.py +++ b/tests/components/dlna_dmr/test_media_player.py @@ -26,7 +26,6 @@ from homeassistant.components.dlna_dmr.const import ( CONF_CALLBACK_URL_OVERRIDE, CONF_LISTEN_PORT, CONF_POLL_AVAILABILITY, - DOMAIN as DLNA_DOMAIN, ) from homeassistant.components.dlna_dmr.data import EventListenAddr from homeassistant.components.dlna_dmr.media_player import DlnaDmrEntity @@ -81,7 +80,7 @@ pytestmark = pytest.mark.usefixtures("domain_data_mock") async def setup_mock_component(hass: HomeAssistant, mock_entry: MockConfigEntry) -> str: """Set up a mock DlnaDmrEntity with the given configuration.""" mock_entry.add_to_hass(hass) - assert await async_setup_component(hass, DLNA_DOMAIN, {}) is True + assert await hass.config_entries.async_setup(mock_entry.entry_id) is True await hass.async_block_till_done() entries = async_entries_for_config_entry(async_get_er(hass), mock_entry.entry_id) diff --git a/tests/components/dremel_3d_printer/test_init.py b/tests/components/dremel_3d_printer/test_init.py index 2740b638316..fa41b74a5d2 100644 --- a/tests/components/dremel_3d_printer/test_init.py +++ b/tests/components/dremel_3d_printer/test_init.py @@ -74,12 +74,14 @@ async def test_update_failed( async def test_device_info( - hass: HomeAssistant, connection, config_entry: MockConfigEntry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + connection, + config_entry: MockConfigEntry, ) -> None: """Test device info.""" await hass.config_entries.async_setup(config_entry.entry_id) assert await async_setup_component(hass, DOMAIN, {}) - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={(DOMAIN, config_entry.unique_id)} ) diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index c4bbe9a7086..5c34fbd9e35 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -2,14 +2,17 @@ import asyncio from itertools import chain, repeat import os +from typing import Any from unittest.mock import DEFAULT, AsyncMock, MagicMock, patch, sentinel +import pytest import serial import serial.tools.list_ports from homeassistant import config_entries, data_entry_flow from homeassistant.components.dsmr import DOMAIN, config_flow from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry @@ -123,9 +126,68 @@ async def test_setup_network_rfxtrx( assert result["data"] == {**entry_data, **SERIAL_DATA} +@pytest.mark.parametrize( + ("version", "entry_data"), + [ + ( + "2.2", + { + "port": "/dev/ttyUSB1234", + "dsmr_version": "2.2", + "protocol": "dsmr_protocol", + "serial_id": "12345678", + "serial_id_gas": "123456789", + }, + ), + ( + "5B", + { + "port": "/dev/ttyUSB1234", + "dsmr_version": "5B", + "protocol": "dsmr_protocol", + "serial_id": "12345678", + "serial_id_gas": "123456789", + }, + ), + ( + "5L", + { + "port": "/dev/ttyUSB1234", + "dsmr_version": "5L", + "protocol": "dsmr_protocol", + "serial_id": "12345678", + "serial_id_gas": "123456789", + }, + ), + ( + "5S", + { + "port": "/dev/ttyUSB1234", + "dsmr_version": "5S", + "protocol": "dsmr_protocol", + "serial_id": None, + "serial_id_gas": None, + }, + ), + ( + "Q3D", + { + "port": "/dev/ttyUSB1234", + "dsmr_version": "Q3D", + "protocol": "dsmr_protocol", + "serial_id": "12345678", + "serial_id_gas": None, + }, + ), + ], +) @patch("serial.tools.list_ports.comports", return_value=[com_port()]) async def test_setup_serial( - com_mock, hass: HomeAssistant, dsmr_connection_send_validate_fixture + com_mock, + hass: HomeAssistant, + dsmr_connection_send_validate_fixture, + version: str, + entry_data: dict[str, Any], ) -> None: """Test we can setup serial.""" port = com_port() @@ -134,7 +196,7 @@ async def test_setup_serial( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] is None @@ -143,26 +205,20 @@ async def test_setup_serial( {"type": "Serial"}, ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "setup_serial" assert result["errors"] == {} with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"port": port.device, "dsmr_version": "2.2"}, + {"port": port.device, "dsmr_version": version}, ) await hass.async_block_till_done() - entry_data = { - "port": port.device, - "dsmr_version": "2.2", - "protocol": "dsmr_protocol", - } - - assert result["type"] == "create_entry" + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == port.device - assert result["data"] == {**entry_data, **SERIAL_DATA} + assert result["data"] == entry_data @patch("serial.tools.list_ports.comports", return_value=[com_port()]) @@ -215,181 +271,6 @@ async def test_setup_serial_rfxtrx( assert result["data"] == {**entry_data, **SERIAL_DATA} -@patch("serial.tools.list_ports.comports", return_value=[com_port()]) -async def test_setup_5B( - com_mock, hass: HomeAssistant, dsmr_connection_send_validate_fixture -) -> None: - """Test we can setup serial.""" - port = com_port() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"type": "Serial"}, - ) - - assert result["type"] == "form" - assert result["step_id"] == "setup_serial" - assert result["errors"] == {} - - with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"port": port.device, "dsmr_version": "5B"}, - ) - await hass.async_block_till_done() - - entry_data = { - "port": port.device, - "dsmr_version": "5B", - "protocol": "dsmr_protocol", - "serial_id": "12345678", - "serial_id_gas": "123456789", - } - - assert result["type"] == "create_entry" - assert result["title"] == port.device - assert result["data"] == entry_data - - -@patch("serial.tools.list_ports.comports", return_value=[com_port()]) -async def test_setup_5L( - com_mock, hass: HomeAssistant, dsmr_connection_send_validate_fixture -) -> None: - """Test we can setup serial.""" - port = com_port() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"type": "Serial"}, - ) - - assert result["type"] == "form" - assert result["step_id"] == "setup_serial" - assert result["errors"] == {} - - with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"port": port.device, "dsmr_version": "5L"}, - ) - await hass.async_block_till_done() - - entry_data = { - "port": port.device, - "dsmr_version": "5L", - "protocol": "dsmr_protocol", - "serial_id": "12345678", - "serial_id_gas": "123456789", - } - - assert result["type"] == "create_entry" - assert result["title"] == port.device - assert result["data"] == entry_data - - -@patch("serial.tools.list_ports.comports", return_value=[com_port()]) -async def test_setup_5S( - com_mock, hass: HomeAssistant, dsmr_connection_send_validate_fixture -) -> None: - """Test we can setup serial.""" - port = com_port() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"type": "Serial"}, - ) - - assert result["type"] == "form" - assert result["step_id"] == "setup_serial" - assert result["errors"] == {} - - with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"port": port.device, "dsmr_version": "5S"} - ) - await hass.async_block_till_done() - - entry_data = { - "port": port.device, - "dsmr_version": "5S", - "protocol": "dsmr_protocol", - "serial_id": None, - "serial_id_gas": None, - } - - assert result["type"] == "create_entry" - assert result["title"] == port.device - assert result["data"] == entry_data - - -@patch("serial.tools.list_ports.comports", return_value=[com_port()]) -async def test_setup_Q3D( - com_mock, hass: HomeAssistant, dsmr_connection_send_validate_fixture -) -> None: - """Test we can setup serial.""" - port = com_port() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"type": "Serial"}, - ) - - assert result["type"] == "form" - assert result["step_id"] == "setup_serial" - assert result["errors"] == {} - - with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"port": port.device, "dsmr_version": "Q3D"}, - ) - await hass.async_block_till_done() - - entry_data = { - "port": port.device, - "dsmr_version": "Q3D", - "protocol": "dsmr_protocol", - "serial_id": "12345678", - "serial_id_gas": None, - } - - assert result["type"] == "create_entry" - assert result["title"] == port.device - assert result["data"] == entry_data - - @patch("serial.tools.list_ports.comports", return_value=[com_port()]) async def test_setup_serial_manual( com_mock, hass: HomeAssistant, dsmr_connection_send_validate_fixture @@ -594,7 +475,6 @@ async def test_options_flow(hass: HomeAssistant) -> None: "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 30, } entry = MockConfigEntry( diff --git a/tests/components/dsmr/test_init.py b/tests/components/dsmr/test_init.py index 567df0279b6..231cd65d768 100644 --- a/tests/components/dsmr/test_init.py +++ b/tests/components/dsmr/test_init.py @@ -85,6 +85,7 @@ from tests.common import MockConfigEntry ) async def test_migrate_unique_id( hass: HomeAssistant, + entity_registry: er.EntityRegistry, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock], dsmr_version: str, old_unique_id: str, @@ -98,7 +99,6 @@ async def test_migrate_unique_id( "port": "/dev/ttyUSB0", "dsmr_version": dsmr_version, "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", }, @@ -109,7 +109,6 @@ async def test_migrate_unique_id( mock_entry.add_to_hass(hass) - entity_registry = er.async_get(hass) entity: er.RegistryEntry = entity_registry.async_get_or_create( suggested_object_id="my_sensor", disabled_by=None, diff --git a/tests/components/dsmr/test_mbus_migration.py b/tests/components/dsmr/test_mbus_migration.py new file mode 100644 index 00000000000..99513b9a2a8 --- /dev/null +++ b/tests/components/dsmr/test_mbus_migration.py @@ -0,0 +1,210 @@ +"""Tests for the DSMR integration.""" +import datetime +from decimal import Decimal + +from homeassistant.components.dsmr.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_migrate_gas_to_mbus( + hass: HomeAssistant, entity_registry: er.EntityRegistry, dsmr_connection_fixture +) -> None: + """Test migration of unique_id.""" + (connection_factory, transport, protocol) = dsmr_connection_fixture + + from dsmr_parser.obis_references import ( + BELGIUM_MBUS1_DEVICE_TYPE, + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS1_METER_READING2, + ) + from dsmr_parser.objects import CosemObject, MBusObject + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="/dev/ttyUSB0", + data={ + "port": "/dev/ttyUSB0", + "dsmr_version": "5B", + "precision": 4, + "serial_id": "1234", + "serial_id_gas": "37464C4F32313139303333373331", + }, + options={ + "time_between_update": 0, + }, + ) + + mock_entry.add_to_hass(hass) + + old_unique_id = "37464C4F32313139303333373331_belgium_5min_gas_meter_reading" + + device_registry = hass.helpers.device_registry.async_get(hass) + device = device_registry.async_get_or_create( + config_entry_id=mock_entry.entry_id, + identifiers={(DOMAIN, mock_entry.entry_id)}, + name="Gas Meter", + ) + await hass.async_block_till_done() + + entity: er.RegistryEntry = entity_registry.async_get_or_create( + suggested_object_id="gas_meter_reading", + disabled_by=None, + domain=SENSOR_DOMAIN, + platform=DOMAIN, + device_id=device.id, + unique_id=old_unique_id, + config_entry=mock_entry, + ) + assert entity.unique_id == old_unique_id + await hass.async_block_till_done() + + telegram = { + BELGIUM_MBUS1_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS1_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373331", "unit": ""}], + ), + BELGIUM_MBUS1_METER_READING2: MBusObject( + BELGIUM_MBUS1_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642213)}, + {"value": Decimal(745.695), "unit": "m3"}, + ], + ), + } + + assert await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() + + dev_entities = er.async_entries_for_device( + entity_registry, device.id, include_disabled_entities=True + ) + assert not dev_entities + + assert ( + entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id) + is None + ) + assert ( + entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, "37464C4F32313139303333373331" + ) + == "sensor.gas_meter_reading" + ) + + +async def test_migrate_gas_to_mbus_exists( + hass: HomeAssistant, entity_registry: er.EntityRegistry, dsmr_connection_fixture +) -> None: + """Test migration of unique_id.""" + (connection_factory, transport, protocol) = dsmr_connection_fixture + + from dsmr_parser.obis_references import ( + BELGIUM_MBUS1_DEVICE_TYPE, + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS1_METER_READING2, + ) + from dsmr_parser.objects import CosemObject, MBusObject + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="/dev/ttyUSB0", + data={ + "port": "/dev/ttyUSB0", + "dsmr_version": "5B", + "precision": 4, + "serial_id": "1234", + "serial_id_gas": "37464C4F32313139303333373331", + }, + options={ + "time_between_update": 0, + }, + ) + + mock_entry.add_to_hass(hass) + + old_unique_id = "37464C4F32313139303333373331_belgium_5min_gas_meter_reading" + + device_registry = hass.helpers.device_registry.async_get(hass) + device = device_registry.async_get_or_create( + config_entry_id=mock_entry.entry_id, + identifiers={(DOMAIN, mock_entry.entry_id)}, + name="Gas Meter", + ) + await hass.async_block_till_done() + + entity: er.RegistryEntry = entity_registry.async_get_or_create( + suggested_object_id="gas_meter_reading", + disabled_by=None, + domain=SENSOR_DOMAIN, + platform=DOMAIN, + device_id=device.id, + unique_id=old_unique_id, + config_entry=mock_entry, + ) + assert entity.unique_id == old_unique_id + + device2 = device_registry.async_get_or_create( + config_entry_id=mock_entry.entry_id, + identifiers={(DOMAIN, "37464C4F32313139303333373331")}, + name="Gas Meter", + ) + await hass.async_block_till_done() + + entity_registry.async_get_or_create( + suggested_object_id="gas_meter_reading_alt", + disabled_by=None, + domain=SENSOR_DOMAIN, + platform=DOMAIN, + device_id=device2.id, + unique_id="37464C4F32313139303333373331", + config_entry=mock_entry, + ) + await hass.async_block_till_done() + + telegram = { + BELGIUM_MBUS1_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS1_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373331", "unit": ""}], + ), + BELGIUM_MBUS1_METER_READING2: MBusObject( + BELGIUM_MBUS1_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642213)}, + {"value": Decimal(745.695), "unit": "m3"}, + ], + ), + } + + assert await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() + + assert ( + entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id) + == "sensor.gas_meter_reading" + ) diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index e7f0e715f59..d3bfabdc0c6 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -8,19 +8,8 @@ import asyncio import datetime from decimal import Decimal from itertools import chain, repeat -from typing import Literal from unittest.mock import DEFAULT, MagicMock -from dsmr_parser.obis_references import ( - BELGIUM_MBUS1_METER_READING1, - BELGIUM_MBUS1_METER_READING2, - BELGIUM_MBUS2_METER_READING1, - BELGIUM_MBUS2_METER_READING2, - BELGIUM_MBUS3_METER_READING1, - BELGIUM_MBUS3_METER_READING2, - BELGIUM_MBUS4_METER_READING1, - BELGIUM_MBUS4_METER_READING2, -) import pytest from homeassistant import config_entries @@ -35,6 +24,7 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, + STATE_UNKNOWN, UnitOfEnergy, UnitOfPower, UnitOfVolume, @@ -45,7 +35,9 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, patch -async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> None: +async def test_default_setup( + hass: HomeAssistant, entity_registry: er.EntityRegistry, dsmr_connection_fixture +) -> None: """Test the default setup.""" (connection_factory, transport, protocol) = dsmr_connection_fixture @@ -60,7 +52,6 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -102,13 +93,11 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No # after receiving telegram entities need to have the chance to be created await hass.async_block_till_done() - registry = er.async_get(hass) - - entry = registry.async_get("sensor.electricity_meter_power_consumption") + entry = entity_registry.async_get("sensor.electricity_meter_power_consumption") assert entry assert entry.unique_id == "1234_current_electricity_usage" - entry = registry.async_get("sensor.gas_meter_gas_consumption") + entry = entity_registry.async_get("sensor.gas_meter_gas_consumption") assert entry assert entry.unique_id == "5678_gas_meter_reading" @@ -145,8 +134,8 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser telegram_callback(telegram) - # after receiving telegram entities need to have the chance to update - await asyncio.sleep(0) + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() # ensure entities have new state value after incoming telegram power_consumption = hass.states.get("sensor.electricity_meter_power_consumption") @@ -184,7 +173,9 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No ) -async def test_setup_only_energy(hass: HomeAssistant, dsmr_connection_fixture) -> None: +async def test_setup_only_energy( + hass: HomeAssistant, entity_registry: er.EntityRegistry, dsmr_connection_fixture +) -> None: """Test the default setup.""" (connection_factory, transport, protocol) = dsmr_connection_fixture @@ -198,7 +189,6 @@ async def test_setup_only_energy(hass: HomeAssistant, dsmr_connection_fixture) - "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", } entry_options = { @@ -232,13 +222,11 @@ async def test_setup_only_energy(hass: HomeAssistant, dsmr_connection_fixture) - # after receiving telegram entities need to have the chance to be created await hass.async_block_till_done() - registry = er.async_get(hass) - - entry = registry.async_get("sensor.electricity_meter_power_consumption") + entry = entity_registry.async_get("sensor.electricity_meter_power_consumption") assert entry assert entry.unique_id == "1234_current_electricity_usage" - entry = registry.async_get("sensor.gas_meter_gas_consumption") + entry = entity_registry.async_get("sensor.gas_meter_gas_consumption") assert not entry @@ -256,7 +244,6 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: "port": "/dev/ttyUSB0", "dsmr_version": "4", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -321,7 +308,17 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: ) -async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: +@pytest.mark.parametrize( + ("value", "state"), + [ + (Decimal(745.690), "745.69"), + (Decimal(745.695), "745.695"), + (Decimal(0.000), STATE_UNKNOWN), + ], +) +async def test_v5_meter( + hass: HomeAssistant, dsmr_connection_fixture, value: Decimal, state: str +) -> None: """Test if v5 meter is correctly parsed.""" (connection_factory, transport, protocol) = dsmr_connection_fixture @@ -335,7 +332,6 @@ async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: "port": "/dev/ttyUSB0", "dsmr_version": "5", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -348,7 +344,7 @@ async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: HOURLY_GAS_METER_READING, [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": value, "unit": "m3"}, ], ), ELECTRICITY_ACTIVE_TARIFF: CosemObject( @@ -384,7 +380,7 @@ async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") - assert gas_consumption.state == "745.695" + assert gas_consumption.state == state assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert ( gas_consumption.attributes.get(ATTR_STATE_CLASS) @@ -411,7 +407,6 @@ async def test_luxembourg_meter(hass: HomeAssistant, dsmr_connection_fixture) -> "port": "/dev/ttyUSB0", "dsmr_version": "5L", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -495,10 +490,18 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No from dsmr_parser.obis_references import ( BELGIUM_CURRENT_AVERAGE_DEMAND, BELGIUM_MAXIMUM_DEMAND_MONTH, + BELGIUM_MBUS1_DEVICE_TYPE, + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, BELGIUM_MBUS1_METER_READING2, - BELGIUM_MBUS2_METER_READING2, + BELGIUM_MBUS2_DEVICE_TYPE, + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS2_METER_READING1, + BELGIUM_MBUS3_DEVICE_TYPE, + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, BELGIUM_MBUS3_METER_READING2, - BELGIUM_MBUS4_METER_READING2, + BELGIUM_MBUS4_DEVICE_TYPE, + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS4_METER_READING1, ELECTRICITY_ACTIVE_TARIFF, ) from dsmr_parser.objects import CosemObject, MBusObject @@ -507,43 +510,14 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", - "serial_id_gas": "5678", + "serial_id_gas": None, } entry_options = { "time_between_update": 0, } telegram = { - BELGIUM_MBUS1_METER_READING2: MBusObject( - BELGIUM_MBUS1_METER_READING2, - [ - {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, - ], - ), - BELGIUM_MBUS2_METER_READING2: MBusObject( - BELGIUM_MBUS2_METER_READING2, - [ - {"value": datetime.datetime.fromtimestamp(1551642214)}, - {"value": Decimal(745.696), "unit": "m3"}, - ], - ), - BELGIUM_MBUS3_METER_READING2: MBusObject( - BELGIUM_MBUS3_METER_READING2, - [ - {"value": datetime.datetime.fromtimestamp(1551642215)}, - {"value": Decimal(745.697), "unit": "m3"}, - ], - ), - BELGIUM_MBUS4_METER_READING2: MBusObject( - BELGIUM_MBUS4_METER_READING2, - [ - {"value": datetime.datetime.fromtimestamp(1551642216)}, - {"value": Decimal(745.698), "unit": "m3"}, - ], - ), BELGIUM_CURRENT_AVERAGE_DEMAND: CosemObject( BELGIUM_CURRENT_AVERAGE_DEMAND, [{"value": Decimal(1.75), "unit": "kW"}], @@ -555,6 +529,62 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No {"value": Decimal(4.11), "unit": "kW"}, ], ), + BELGIUM_MBUS1_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS1_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373331", "unit": ""}], + ), + BELGIUM_MBUS1_METER_READING2: MBusObject( + BELGIUM_MBUS1_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642213)}, + {"value": Decimal(745.695), "unit": "m3"}, + ], + ), + BELGIUM_MBUS2_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS2_DEVICE_TYPE, [{"value": "007", "unit": ""}] + ), + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373332", "unit": ""}], + ), + BELGIUM_MBUS2_METER_READING1: MBusObject( + BELGIUM_MBUS2_METER_READING1, + [ + {"value": datetime.datetime.fromtimestamp(1551642214)}, + {"value": Decimal(678.695), "unit": "m3"}, + ], + ), + BELGIUM_MBUS3_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS3_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373333", "unit": ""}], + ), + BELGIUM_MBUS3_METER_READING2: MBusObject( + BELGIUM_MBUS3_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642215)}, + {"value": Decimal(12.12), "unit": "m3"}, + ], + ), + BELGIUM_MBUS4_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS4_DEVICE_TYPE, [{"value": "007", "unit": ""}] + ), + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373334", "unit": ""}], + ), + BELGIUM_MBUS4_METER_READING1: MBusObject( + BELGIUM_MBUS4_METER_READING1, + [ + {"value": datetime.datetime.fromtimestamp(1551642216)}, + {"value": Decimal(13.13), "unit": "m3"}, + ], + ), ELECTRICITY_ACTIVE_TARIFF: CosemObject( ELECTRICITY_ACTIVE_TARIFF, [{"value": "0001", "unit": ""}] ), @@ -600,7 +630,7 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No assert max_demand.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.KILO_WATT assert max_demand.attributes.get(ATTR_STATE_CLASS) is None - # check if gas consumption is parsed correctly + # check if gas consumption mbus1 is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") assert gas_consumption.state == "745.695" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS @@ -613,81 +643,136 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No == UnitOfVolume.CUBIC_METERS ) + # check if water usage mbus2 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption") + assert water_consumption.state == "678.695" + assert ( + water_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER + ) + assert ( + water_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + water_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) -@pytest.mark.parametrize( - ("key1", "key2", "key3", "gas_value"), - [ - ( - BELGIUM_MBUS1_METER_READING1, - BELGIUM_MBUS2_METER_READING2, - BELGIUM_MBUS3_METER_READING1, - "745.696", - ), - ( - BELGIUM_MBUS1_METER_READING2, - BELGIUM_MBUS2_METER_READING1, - BELGIUM_MBUS3_METER_READING2, - "745.695", - ), - ( - BELGIUM_MBUS4_METER_READING2, - BELGIUM_MBUS2_METER_READING1, - BELGIUM_MBUS3_METER_READING1, - "745.695", - ), - ( - BELGIUM_MBUS4_METER_READING1, - BELGIUM_MBUS2_METER_READING1, - BELGIUM_MBUS3_METER_READING2, - "745.697", - ), - ], -) -async def test_belgian_meter_alt( - hass: HomeAssistant, - dsmr_connection_fixture, - key1: Literal, - key2: Literal, - key3: Literal, - gas_value: str, -) -> None: + # check if gas consumption mbus1 is parsed correctly + gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption_2") + assert gas_consumption.state == "12.12" + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS + assert ( + gas_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + + # check if water usage mbus2 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption_2") + assert water_consumption.state == "13.13" + assert ( + water_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER + ) + assert ( + water_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + water_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + + +async def test_belgian_meter_alt(hass: HomeAssistant, dsmr_connection_fixture) -> None: """Test if Belgian meter is correctly parsed.""" (connection_factory, transport, protocol) = dsmr_connection_fixture - from dsmr_parser.objects import MBusObject + from dsmr_parser.obis_references import ( + BELGIUM_MBUS1_DEVICE_TYPE, + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS1_METER_READING1, + BELGIUM_MBUS2_DEVICE_TYPE, + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS2_METER_READING2, + BELGIUM_MBUS3_DEVICE_TYPE, + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS3_METER_READING1, + BELGIUM_MBUS4_DEVICE_TYPE, + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS4_METER_READING2, + ) + from dsmr_parser.objects import CosemObject, MBusObject entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", - "serial_id_gas": "5678", + "serial_id_gas": None, } entry_options = { "time_between_update": 0, } telegram = { - key1: MBusObject( - key1, - [ - {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, - ], + BELGIUM_MBUS1_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS1_DEVICE_TYPE, [{"value": "007", "unit": ""}] ), - key2: MBusObject( - key2, - [ - {"value": datetime.datetime.fromtimestamp(1551642214)}, - {"value": Decimal(745.696), "unit": "m3"}, - ], + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373331", "unit": ""}], ), - key3: MBusObject( - key3, + BELGIUM_MBUS1_METER_READING1: MBusObject( + BELGIUM_MBUS1_METER_READING1, [ {"value": datetime.datetime.fromtimestamp(1551642215)}, - {"value": Decimal(745.697), "unit": "m3"}, + {"value": Decimal(123.456), "unit": "m3"}, + ], + ), + BELGIUM_MBUS2_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS2_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373332", "unit": ""}], + ), + BELGIUM_MBUS2_METER_READING2: MBusObject( + BELGIUM_MBUS2_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642216)}, + {"value": Decimal(678.901), "unit": "m3"}, + ], + ), + BELGIUM_MBUS3_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS3_DEVICE_TYPE, [{"value": "007", "unit": ""}] + ), + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373333", "unit": ""}], + ), + BELGIUM_MBUS3_METER_READING1: MBusObject( + BELGIUM_MBUS3_METER_READING1, + [ + {"value": datetime.datetime.fromtimestamp(1551642217)}, + {"value": Decimal(12.12), "unit": "m3"}, + ], + ), + BELGIUM_MBUS4_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS4_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373334", "unit": ""}], + ), + BELGIUM_MBUS4_METER_READING2: MBusObject( + BELGIUM_MBUS4_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642218)}, + {"value": Decimal(13.13), "unit": "m3"}, ], ), } @@ -709,9 +794,24 @@ async def test_belgian_meter_alt( # after receiving telegram entities need to have the chance to be created await hass.async_block_till_done() - # check if gas consumption is parsed correctly + # check if water usage mbus1 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption") + assert water_consumption.state == "123.456" + assert ( + water_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER + ) + assert ( + water_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + water_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + + # check if gas consumption mbus2 is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") - assert gas_consumption.state == gas_value + assert gas_consumption.state == "678.901" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert ( gas_consumption.attributes.get(ATTR_STATE_CLASS) @@ -722,6 +822,156 @@ async def test_belgian_meter_alt( == UnitOfVolume.CUBIC_METERS ) + # check if water usage mbus3 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption_2") + assert water_consumption.state == "12.12" + assert ( + water_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER + ) + assert ( + water_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + water_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + + # check if gas consumption mbus4 is parsed correctly + gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption_2") + assert gas_consumption.state == "13.13" + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS + assert ( + gas_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + + +async def test_belgian_meter_mbus(hass: HomeAssistant, dsmr_connection_fixture) -> None: + """Test if Belgian meter is correctly parsed.""" + (connection_factory, transport, protocol) = dsmr_connection_fixture + + from dsmr_parser.obis_references import ( + BELGIUM_MBUS1_DEVICE_TYPE, + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS2_DEVICE_TYPE, + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS3_DEVICE_TYPE, + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS3_METER_READING2, + BELGIUM_MBUS4_DEVICE_TYPE, + BELGIUM_MBUS4_METER_READING1, + ELECTRICITY_ACTIVE_TARIFF, + ) + from dsmr_parser.objects import CosemObject, MBusObject + + entry_data = { + "port": "/dev/ttyUSB0", + "dsmr_version": "5B", + "precision": 4, + "serial_id": "1234", + "serial_id_gas": None, + } + entry_options = { + "time_between_update": 0, + } + + telegram = { + ELECTRICITY_ACTIVE_TARIFF: CosemObject( + ELECTRICITY_ACTIVE_TARIFF, [{"value": "0003", "unit": ""}] + ), + BELGIUM_MBUS1_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS1_DEVICE_TYPE, [{"value": "006", "unit": ""}] + ), + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373331", "unit": ""}], + ), + BELGIUM_MBUS2_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS2_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373332", "unit": ""}], + ), + BELGIUM_MBUS3_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS3_DEVICE_TYPE, [{"value": "007", "unit": ""}] + ), + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373333", "unit": ""}], + ), + BELGIUM_MBUS3_METER_READING2: MBusObject( + BELGIUM_MBUS3_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642217)}, + {"value": Decimal(12.12), "unit": "m3"}, + ], + ), + BELGIUM_MBUS4_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS4_DEVICE_TYPE, [{"value": "007", "unit": ""}] + ), + BELGIUM_MBUS4_METER_READING1: MBusObject( + BELGIUM_MBUS4_METER_READING1, + [ + {"value": datetime.datetime.fromtimestamp(1551642218)}, + {"value": Decimal(13.13), "unit": "m3"}, + ], + ), + } + + mock_entry = MockConfigEntry( + domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data, options=entry_options + ) + + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() + + # tariff should be translated in human readable and have no unit + active_tariff = hass.states.get("sensor.electricity_meter_active_tariff") + assert active_tariff.state == "unknown" + + # check if gas consumption mbus2 is parsed correctly + gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") + assert gas_consumption is None + + # check if water usage mbus3 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption_2") + assert water_consumption is None + + # check if gas consumption mbus4 is parsed correctly + gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption_2") + assert gas_consumption is None + + # check if gas consumption mbus4 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption") + assert water_consumption.state == "13.13" + assert ( + water_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER + ) + assert ( + water_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + water_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) -> None: """Test if Belgian meter is correctly parsed.""" @@ -734,7 +984,6 @@ async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) - "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -789,7 +1038,6 @@ async def test_swedish_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No "port": "/dev/ttyUSB0", "dsmr_version": "5S", "precision": 4, - "reconnect_interval": 30, "serial_id": None, "serial_id_gas": None, } @@ -864,7 +1112,6 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: "port": "/dev/ttyUSB0", "dsmr_version": "Q3D", "precision": 4, - "reconnect_interval": 30, "serial_id": None, "serial_id_gas": None, } @@ -938,7 +1185,6 @@ async def test_tcp(hass: HomeAssistant, dsmr_connection_fixture) -> None: "dsmr_version": "2.2", "protocol": "dsmr_protocol", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -966,7 +1212,6 @@ async def test_rfxtrx_tcp(hass: HomeAssistant, rfxtrx_dsmr_connection_fixture) - "dsmr_version": "2.2", "protocol": "rfxtrx_dsmr_protocol", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -984,6 +1229,7 @@ async def test_rfxtrx_tcp(hass: HomeAssistant, rfxtrx_dsmr_connection_fixture) - assert connection_factory.call_args_list[0][0][1] == "1234" +@patch("homeassistant.components.dsmr.sensor.DEFAULT_RECONNECT_INTERVAL", 0) async def test_connection_errors_retry( hass: HomeAssistant, dsmr_connection_fixture ) -> None: @@ -994,7 +1240,6 @@ async def test_connection_errors_retry( "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 0, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1023,6 +1268,7 @@ async def test_connection_errors_retry( assert first_fail_connection_factory.call_count >= 2, "connecting not retried" +@patch("homeassistant.components.dsmr.sensor.DEFAULT_RECONNECT_INTERVAL", 0) async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: """If transport disconnects, the connection should be retried.""" from dsmr_parser.obis_references import ( @@ -1037,7 +1283,6 @@ async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 0, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1120,7 +1365,6 @@ async def test_gas_meter_providing_energy_reading( "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } diff --git a/tests/components/dwd_weather_warnings/test_config_flow.py b/tests/components/dwd_weather_warnings/test_config_flow.py index 819d98e25ef..625532a4f04 100644 --- a/tests/components/dwd_weather_warnings/test_config_flow.py +++ b/tests/components/dwd_weather_warnings/test_config_flow.py @@ -12,7 +12,7 @@ from homeassistant.components.dwd_weather_warnings.const import ( CURRENT_WARNING_SENSOR, DOMAIN, ) -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -70,81 +70,6 @@ async def test_create_entry(hass: HomeAssistant) -> None: } -async def test_import_flow_full_data(hass: HomeAssistant) -> None: - """Test import of a full YAML configuration with both success and failure.""" - # Test abort due to invalid identifier. - with patch( - "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", - return_value=False, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=DEMO_YAML_CONFIGURATION.copy(), - ) - - await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "invalid_identifier" - - # Test successful import. - with patch( - "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=DEMO_YAML_CONFIGURATION.copy(), - ) - - await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Unit Test" - assert result["data"] == { - CONF_REGION_IDENTIFIER: "807111000", - } - - -async def test_import_flow_no_name(hass: HomeAssistant) -> None: - """Test a successful import of a YAML configuration with no name set.""" - data = DEMO_YAML_CONFIGURATION.copy() - data.pop(CONF_NAME) - - with patch( - "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=data - ) - - await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "807111000" - assert result["data"] == { - CONF_REGION_IDENTIFIER: "807111000", - } - - -async def test_import_flow_already_configured(hass: HomeAssistant) -> None: - """Test aborting, if the warncell ID / name is already configured during the import.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=DEMO_CONFIG_ENTRY.copy(), - unique_id=DEMO_CONFIG_ENTRY[CONF_REGION_IDENTIFIER], - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=DEMO_YAML_CONFIGURATION.copy() - ) - - await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_configured" - - async def test_config_flow_already_configured(hass: HomeAssistant) -> None: """Test aborting, if the warncell ID / name is already configured during the config.""" entry = MockConfigEntry( diff --git a/tests/components/dynalite/common.py b/tests/components/dynalite/common.py index 446cdc74c0b..355a1285a56 100644 --- a/tests/components/dynalite/common.py +++ b/tests/components/dynalite/common.py @@ -3,7 +3,6 @@ from unittest.mock import AsyncMock, Mock, call, patch from homeassistant.components import dynalite from homeassistant.const import ATTR_SERVICE -from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry @@ -23,8 +22,6 @@ def create_mock_device(platform, spec): async def get_entry_id_from_hass(hass): """Get the config entry id from hass.""" - ent_reg = er.async_get(hass) - assert ent_reg conf_entries = hass.config_entries.async_entries(dynalite.DOMAIN) assert len(conf_entries) == 1 return conf_entries[0].entry_id diff --git a/tests/components/easyenergy/test_sensor.py b/tests/components/easyenergy/test_sensor.py index 98e94197db9..afc3a12d6a2 100644 --- a/tests/components/easyenergy/test_sensor.py +++ b/tests/components/easyenergy/test_sensor.py @@ -32,12 +32,13 @@ from tests.common import MockConfigEntry @pytest.mark.freeze_time("2023-01-19 15:00:00") async def test_energy_usage_today( - hass: HomeAssistant, init_integration: MockConfigEntry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + init_integration: MockConfigEntry, ) -> None: """Test the easyEnergy - Energy usage sensors.""" entry_id = init_integration.entry_id - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) # Current usage energy price sensor state = hass.states.get("sensor.easyenergy_today_energy_usage_current_hour_price") @@ -146,12 +147,13 @@ async def test_energy_usage_today( @pytest.mark.freeze_time("2023-01-19 15:00:00") async def test_energy_return_today( - hass: HomeAssistant, init_integration: MockConfigEntry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + init_integration: MockConfigEntry, ) -> None: """Test the easyEnergy - Energy return sensors.""" entry_id = init_integration.entry_id - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) # Current return energy price sensor state = hass.states.get("sensor.easyenergy_today_energy_return_current_hour_price") @@ -261,12 +263,13 @@ async def test_energy_return_today( @pytest.mark.freeze_time("2023-01-19 10:00:00") async def test_gas_today( - hass: HomeAssistant, init_integration: MockConfigEntry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + init_integration: MockConfigEntry, ) -> None: """Test the easyEnergy - Gas sensors.""" entry_id = init_integration.entry_id - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) # Current gas price sensor state = hass.states.get("sensor.easyenergy_today_gas_current_hour_price") diff --git a/tests/components/ecobee/test_config_flow.py b/tests/components/ecobee/test_config_flow.py index 7d79a10e912..a0f34e3cd21 100644 --- a/tests/components/ecobee/test_config_flow.py +++ b/tests/components/ecobee/test_config_flow.py @@ -198,9 +198,7 @@ async def test_import_flow_triggered_with_ecobee_conf_and_valid_data_and_stale_t return_value=MOCK_ECOBEE_CONF, ), patch( "homeassistant.components.ecobee.config_flow.Ecobee" - ) as mock_ecobee, patch.object( - flow, "async_step_user" - ) as mock_async_step_user: + ) as mock_ecobee, patch.object(flow, "async_step_user") as mock_async_step_user: mock_ecobee = mock_ecobee.return_value mock_ecobee.refresh_tokens.return_value = False diff --git a/tests/components/efergy/test_init.py b/tests/components/efergy/test_init.py index e82d6615923..df6d6a7b112 100644 --- a/tests/components/efergy/test_init.py +++ b/tests/components/efergy/test_init.py @@ -47,11 +47,12 @@ async def test_async_setup_entry_auth_failed(hass: HomeAssistant) -> None: async def test_device_info( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test device info.""" entry = await setup_platform(hass, aioclient_mock, SENSOR_DOMAIN) - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) diff --git a/tests/components/efergy/test_sensor.py b/tests/components/efergy/test_sensor.py index f8eb889d3c3..45261d45933 100644 --- a/tests/components/efergy/test_sensor.py +++ b/tests/components/efergy/test_sensor.py @@ -17,7 +17,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry import homeassistant.util.dt as dt_util from . import MULTI_SENSOR_TOKEN, mock_responses, setup_platform @@ -27,13 +26,14 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_sensor_readings( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test for successfully setting up the Efergy platform.""" for description in SENSOR_TYPES: description.entity_registry_enabled_default = True entry = await setup_platform(hass, aioclient_mock, SENSOR_DOMAIN) - ent_reg: EntityRegistry = er.async_get(hass) state = hass.states.get("sensor.efergy_power_usage") assert state.state == "1580" @@ -85,9 +85,9 @@ async def test_sensor_readings( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.MONETARY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "EUR" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING - entity = ent_reg.async_get("sensor.efergy_power_usage_728386") + entity = entity_registry.async_get("sensor.efergy_power_usage_728386") assert entity.disabled_by is er.RegistryEntryDisabler.INTEGRATION - ent_reg.async_update_entity(entity.entity_id, **{"disabled_by": None}) + entity_registry.async_update_entity(entity.entity_id, **{"disabled_by": None}) await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() state = hass.states.get("sensor.efergy_power_usage_728386") diff --git a/tests/components/electrasmart/test_config_flow.py b/tests/components/electrasmart/test_config_flow.py index f53bea3e96c..929259a0ccf 100644 --- a/tests/components/electrasmart/test_config_flow.py +++ b/tests/components/electrasmart/test_config_flow.py @@ -55,7 +55,8 @@ async def test_one_time_password(hass: HomeAssistant): "electrasmart.api.ElectraAPI.validate_one_time_password", return_value=mock_otp_response, ), patch( - "electrasmart.api.ElectraAPI.fetch_devices", return_value=[] + "electrasmart.api.ElectraAPI.fetch_devices", + return_value=[], ): result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/elgato/conftest.py b/tests/components/elgato/conftest.py index bd1b4242ede..e8be6a4810b 100644 --- a/tests/components/elgato/conftest.py +++ b/tests/components/elgato/conftest.py @@ -70,20 +70,20 @@ def mock_elgato( "homeassistant.components.elgato.config_flow.Elgato", new=elgato_mock ): elgato = elgato_mock.return_value - elgato.info.return_value = Info.parse_raw( + elgato.info.return_value = Info.from_json( load_fixture(f"{device_fixtures}/info.json", DOMAIN) ) - elgato.state.return_value = State.parse_raw( + elgato.state.return_value = State.from_json( load_fixture(f"{device_fixtures}/{state_variant}.json", DOMAIN) ) - elgato.settings.return_value = Settings.parse_raw( + elgato.settings.return_value = Settings.from_json( load_fixture(f"{device_fixtures}/settings.json", DOMAIN) ) # This may, or may not, be a battery-powered device if get_fixture_path(f"{device_fixtures}/battery.json", DOMAIN).exists(): elgato.has_battery.return_value = True - elgato.battery.return_value = BatteryInfo.parse_raw( + elgato.battery.return_value = BatteryInfo.from_json( load_fixture(f"{device_fixtures}/battery.json", DOMAIN) ) else: diff --git a/tests/components/elgato/snapshots/test_diagnostics.ambr b/tests/components/elgato/snapshots/test_diagnostics.ambr index a22dc07f717..c3996c8dd45 100644 --- a/tests/components/elgato/snapshots/test_diagnostics.ambr +++ b/tests/components/elgato/snapshots/test_diagnostics.ambr @@ -2,23 +2,19 @@ # name: test_diagnostics dict({ 'info': dict({ - 'display_name': 'Frenck', + 'displayName': 'Frenck', 'features': list([ 'lights', ]), - 'firmware_build_number': 192, - 'firmware_version': '1.0.3', - 'hardware_board_type': 53, - 'mac_address': None, - 'product_name': 'Elgato Key Light', - 'serial_number': 'CN11A1A00001', - 'wifi': None, + 'firmwareBuildNumber': 192, + 'firmwareVersion': '1.0.3', + 'hardwareBoardType': 53, + 'productName': 'Elgato Key Light', + 'serialNumber': 'CN11A1A00001', }), 'state': dict({ 'brightness': 21, - 'hue': None, - 'on': True, - 'saturation': None, + 'on': 1, 'temperature': 297, }), }) diff --git a/tests/components/elkm1/test_config_flow.py b/tests/components/elkm1/test_config_flow.py index 216fc019778..5e33a8aa4c3 100644 --- a/tests/components/elkm1/test_config_flow.py +++ b/tests/components/elkm1/test_config_flow.py @@ -229,9 +229,7 @@ async def test_form_user_with_insecure_elk_times_out(hass: HomeAssistant) -> Non 0, ), patch( "homeassistant.components.elkm1.config_flow.LOGIN_TIMEOUT", 0 - ), _patch_discovery(), _patch_elk( - elk=mocked_elk - ): + ), _patch_discovery(), _patch_elk(elk=mocked_elk): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index bf1513507f8..f4a1f661f9b 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -88,7 +88,10 @@ async def test_cost_sensor_no_states( async def test_cost_sensor_attributes( - setup_integration, hass: HomeAssistant, hass_storage: dict[str, Any] + setup_integration, + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_storage: dict[str, Any], ) -> None: """Test sensor attributes.""" energy_data = data.EnergyManager.default_preferences() @@ -114,9 +117,8 @@ async def test_cost_sensor_attributes( } await setup_integration(hass) - registry = er.async_get(hass) cost_sensor_entity_id = "sensor.energy_consumption_cost" - entry = registry.async_get(cost_sensor_entity_id) + entry = entity_registry.async_get(cost_sensor_entity_id) assert entry.entity_category is None assert entry.disabled_by is None assert entry.hidden_by == er.RegistryEntryHider.INTEGRATION @@ -145,6 +147,7 @@ async def test_cost_sensor_price_entity_total_increasing( hass: HomeAssistant, hass_storage: dict[str, Any], hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, initial_energy, initial_cost, price_entity, @@ -237,7 +240,6 @@ async def test_cost_sensor_price_entity_total_increasing( assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" - entity_registry = er.async_get(hass) entry = entity_registry.async_get(cost_sensor_entity_id) assert entry postfix = "cost" if flow_type == "flow_from" else "compensation" @@ -357,6 +359,7 @@ async def test_cost_sensor_price_entity_total( hass: HomeAssistant, hass_storage: dict[str, Any], hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, initial_energy, initial_cost, price_entity, @@ -451,7 +454,6 @@ async def test_cost_sensor_price_entity_total( assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" - entity_registry = er.async_get(hass) entry = entity_registry.async_get(cost_sensor_entity_id) assert entry postfix = "cost" if flow_type == "flow_from" else "compensation" @@ -572,6 +574,7 @@ async def test_cost_sensor_price_entity_total_no_reset( hass: HomeAssistant, hass_storage: dict[str, Any], hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, initial_energy, initial_cost, price_entity, @@ -665,7 +668,6 @@ async def test_cost_sensor_price_entity_total_no_reset( assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" - entity_registry = er.async_get(hass) entry = entity_registry.async_get(cost_sensor_entity_id) assert entry postfix = "cost" if flow_type == "flow_from" else "compensation" @@ -1156,7 +1158,10 @@ async def test_cost_sensor_state_class_measurement_no_reset( async def test_inherit_source_unique_id( - setup_integration, hass: HomeAssistant, hass_storage: dict[str, Any] + setup_integration, + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_storage: dict[str, Any], ) -> None: """Test sensor inherits unique ID from source.""" energy_data = data.EnergyManager.default_preferences() @@ -1175,7 +1180,6 @@ async def test_inherit_source_unique_id( "data": energy_data, } - entity_registry = er.async_get(hass) source_entry = entity_registry.async_get_or_create( "sensor", "test", "123456", suggested_object_id="gas_consumption" ) diff --git a/tests/components/enocean/test_switch.py b/tests/components/enocean/test_switch.py index a7aafa6fc73..4ddd54fba05 100644 --- a/tests/components/enocean/test_switch.py +++ b/tests/components/enocean/test_switch.py @@ -22,7 +22,10 @@ SWITCH_CONFIG = { } -async def test_unique_id_migration(hass: HomeAssistant) -> None: +async def test_unique_id_migration( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Test EnOcean switch ID migration.""" entity_name = SWITCH_CONFIG["switch"][0]["name"] @@ -30,8 +33,6 @@ async def test_unique_id_migration(hass: HomeAssistant) -> None: dev_id = SWITCH_CONFIG["switch"][0]["id"] channel = SWITCH_CONFIG["switch"][0]["channel"] - ent_reg = er.async_get(hass) - old_unique_id = f"{combine_hex(dev_id)}" entry = MockConfigEntry(domain=ENOCEAN_DOMAIN, data={"device": "/dev/null"}) @@ -39,7 +40,7 @@ async def test_unique_id_migration(hass: HomeAssistant) -> None: entry.add_to_hass(hass) # Add a switch with an old unique_id to the entity registry - entity_entry = ent_reg.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( SWITCH_DOMAIN, ENOCEAN_DOMAIN, old_unique_id, @@ -63,11 +64,13 @@ async def test_unique_id_migration(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Check that new entry has a new unique_id - entity_entry = ent_reg.async_get(switch_entity_id) + entity_entry = entity_registry.async_get(switch_entity_id) new_unique_id = f"{combine_hex(dev_id)}-{channel}" assert entity_entry.unique_id == new_unique_id assert ( - ent_reg.async_get_entity_id(SWITCH_DOMAIN, ENOCEAN_DOMAIN, old_unique_id) + entity_registry.async_get_entity_id( + SWITCH_DOMAIN, ENOCEAN_DOMAIN, old_unique_id + ) is None ) diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py index 41cbb239129..c1fb03545cb 100644 --- a/tests/components/enphase_envoy/conftest.py +++ b/tests/components/enphase_envoy/conftest.py @@ -89,7 +89,8 @@ async def setup_enphase_envoy_fixture(hass, config, mock_envoy): "homeassistant.components.enphase_envoy.Envoy", return_value=mock_envoy, ), patch( - "homeassistant.components.enphase_envoy.PLATFORMS", [] + "homeassistant.components.enphase_envoy.PLATFORMS", + [], ): assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() diff --git a/tests/components/epson/test_media_player.py b/tests/components/epson/test_media_player.py new file mode 100644 index 00000000000..874a12173d6 --- /dev/null +++ b/tests/components/epson/test_media_player.py @@ -0,0 +1,49 @@ +"""Tests for the epson integration.""" +from datetime import timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.epson.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_set_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +): + """Test the unique id is set on runtime.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Epson", + data={CONF_HOST: "1.1.1.1"}, + entry_id="1cb78c095906279574a0442a1f0003ef", + ) + entry.add_to_hass(hass) + with patch("homeassistant.components.epson.Projector.get_power"): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.unique_id is None + entity_entry = entity_registry.async_get("media_player.epson") + assert entity_entry + assert entity_entry.unique_id == entry.entry_id + with patch( + "homeassistant.components.epson.Projector.get_power", return_value="01" + ), patch( + "homeassistant.components.epson.Projector.get_serial_number", return_value="123" + ), patch( + "homeassistant.components.epson.Projector.get_property", + ): + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + entity_entry = entity_registry.async_get("media_player.epson") + assert entity_entry + assert entity_entry.unique_id == "123" + assert entry.unique_id == "123" diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 7e00fd22a1c..065890fd623 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -15,8 +15,12 @@ from aioesphomeapi import ( ) from homeassistant.components.climate import ( + ATTR_CURRENT_HUMIDITY, ATTR_FAN_MODE, + ATTR_HUMIDITY, ATTR_HVAC_MODE, + ATTR_MAX_HUMIDITY, + ATTR_MIN_HUMIDITY, ATTR_PRESET_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, @@ -25,6 +29,7 @@ from homeassistant.components.climate import ( DOMAIN as CLIMATE_DOMAIN, FAN_HIGH, SERVICE_SET_FAN_MODE, + SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, SERVICE_SET_SWING_MODE, @@ -312,3 +317,63 @@ async def test_climate_entity_with_step_and_target_temp( [call(key=1, swing_mode=ClimateSwingMode.BOTH)] ) mock_client.climate_command.reset_mock() + + +async def test_climate_entity_with_humidity( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic climate entity with humidity.""" + entity_info = [ + ClimateInfo( + object_id="myclimate", + key=1, + name="my climate", + unique_id="my_climate", + supports_current_temperature=True, + supports_two_point_target_temperature=True, + supports_action=True, + visual_min_temperature=10.0, + visual_max_temperature=30.0, + supports_current_humidity=True, + supports_target_humidity=True, + visual_min_humidity=10.1, + visual_max_humidity=29.7, + ) + ] + states = [ + ClimateState( + key=1, + mode=ClimateMode.AUTO, + action=ClimateAction.COOLING, + current_temperature=30, + target_temperature=20, + fan_mode=ClimateFanMode.AUTO, + swing_mode=ClimateSwingMode.BOTH, + current_humidity=20.1, + target_humidity=25.7, + ) + ] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("climate.test_myclimate") + assert state is not None + assert state.state == HVACMode.AUTO + attributes = state.attributes + assert attributes[ATTR_CURRENT_HUMIDITY] == 20 + assert attributes[ATTR_HUMIDITY] == 26 + assert attributes[ATTR_MAX_HUMIDITY] == 30 + assert attributes[ATTR_MIN_HUMIDITY] == 10 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_HUMIDITY: 23}, + blocking=True, + ) + mock_client.climate_command.assert_has_calls([call(key=1, target_humidity=23)]) + mock_client.climate_command.reset_mock() diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index fdc57b2dc24..9a5cb441f28 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -13,7 +13,13 @@ from aioesphomeapi import ( UserService, ) -from homeassistant.const import ATTR_RESTORED, STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import ( + ATTR_RESTORED, + EVENT_HOMEASSISTANT_STOP, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from .conftest import MockESPHomeDevice @@ -231,6 +237,19 @@ async def test_deep_sleep_device( assert state is not None assert state.state == STATE_UNAVAILABLE + await mock_device.mock_connect() + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert state.state == STATE_ON + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + # Verify we do not dispatch any more state updates or + # availability updates after the stop event is fired + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert state.state == STATE_ON + async def test_esphome_device_without_friendly_name( hass: HomeAssistant, diff --git a/tests/components/esphome/test_entry_data.py b/tests/components/esphome/test_entry_data.py index 64484b91e07..0ba43092d01 100644 --- a/tests/components/esphome/test_entry_data.py +++ b/tests/components/esphome/test_entry_data.py @@ -13,12 +13,12 @@ from homeassistant.helpers import entity_registry as er async def test_migrate_entity_unique_id( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_client: APIClient, mock_generic_device_entry, ) -> None: """Test a generic sensor entity unique id migration.""" - ent_reg = er.async_get(hass) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "sensor", "esphome", "my_sensor", @@ -46,10 +46,9 @@ async def test_migrate_entity_unique_id( state = hass.states.get("sensor.old_sensor") assert state is not None assert state.state == "50" - entity_reg = er.async_get(hass) - entry = entity_reg.async_get("sensor.old_sensor") + entry = entity_registry.async_get("sensor.old_sensor") assert entry is not None - assert entity_reg.async_get_entity_id("sensor", "esphome", "my_sensor") is None + assert entity_registry.async_get_entity_id("sensor", "esphome", "my_sensor") is None # Note that ESPHome includes the EntityInfo type in the unique id # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) assert entry.unique_id == "11:22:33:44:55:aa-sensor-mysensor" @@ -57,19 +56,19 @@ async def test_migrate_entity_unique_id( async def test_migrate_entity_unique_id_downgrade_upgrade( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_client: APIClient, mock_generic_device_entry, ) -> None: """Test unique id migration prefers the original entity on downgrade upgrade.""" - ent_reg = er.async_get(hass) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "sensor", "esphome", "my_sensor", suggested_object_id="old_sensor", disabled_by=None, ) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "sensor", "esphome", "11:22:33:44:55:aa-sensor-mysensor", @@ -97,14 +96,16 @@ async def test_migrate_entity_unique_id_downgrade_upgrade( state = hass.states.get("sensor.new_sensor") assert state is not None assert state.state == "50" - entity_reg = er.async_get(hass) - entry = entity_reg.async_get("sensor.new_sensor") + entry = entity_registry.async_get("sensor.new_sensor") assert entry is not None # Confirm we did not touch the entity that was created # on downgrade so when they upgrade again they can delete the # entity that was only created on downgrade and they keep # the original one. - assert entity_reg.async_get_entity_id("sensor", "esphome", "my_sensor") is not None + assert ( + entity_registry.async_get_entity_id("sensor", "esphome", "my_sensor") + is not None + ) # Note that ESPHome includes the EntityInfo type in the unique id # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) assert entry.unique_id == "11:22:33:44:55:aa-sensor-mysensor" diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index d297dddee4a..244e7487ed3 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -1,6 +1,6 @@ """Test ESPHome manager.""" from collections.abc import Awaitable, Callable -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, call from aioesphomeapi import APIClient, DeviceInfo, EntityInfo, EntityState, UserService import pytest @@ -16,6 +16,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component from .conftest import MockESPHomeDevice @@ -332,3 +333,39 @@ async def test_connection_aborted_wrong_device( await hass.async_block_till_done() assert len(new_info.mock_calls) == 1 assert "Unexpected device found at" not in caplog.text + + +async def test_debug_logging( + mock_client: APIClient, + hass: HomeAssistant, + mock_generic_device_entry: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockConfigEntry], + ], +) -> None: + """Test enabling and disabling debug logging.""" + assert await async_setup_component(hass, "logger", {"logger": {}}) + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.esphome": "DEBUG"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_client.set_debug.assert_has_calls([call(True)]) + + mock_client.reset_mock() + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.esphome": "WARNING"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_client.set_debug.assert_has_calls([call(False)]) diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index d7b04f8448c..9ab00421cbc 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -100,7 +100,8 @@ async def test_update_entity( ) as mock_compile, patch( "esphome_dashboard_api.ESPHomeDashboardAPI.upload", return_value=True ) as mock_upload, pytest.raises( - HomeAssistantError, match="compiling" + HomeAssistantError, + match="compiling", ): await hass.services.async_call( "update", @@ -120,7 +121,8 @@ async def test_update_entity( ) as mock_compile, patch( "esphome_dashboard_api.ESPHomeDashboardAPI.upload", return_value=False ) as mock_upload, pytest.raises( - HomeAssistantError, match="OTA" + HomeAssistantError, + match="OTA", ): await hass.services.async_call( "update", diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index 9b6bcf1c6c7..38a33bfdec2 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -1,8 +1,10 @@ """Test ESPHome voice assistant server.""" import asyncio +import io import socket from unittest.mock import Mock, patch +import wave from aioesphomeapi import VoiceAssistantEventType import pytest @@ -335,14 +337,45 @@ async def test_send_tts_called( mock_send_tts.assert_called_with(_TEST_MEDIA_ID) +async def test_send_tts_not_called_when_empty( + hass: HomeAssistant, + voice_assistant_udp_server_v1: VoiceAssistantUDPServer, + voice_assistant_udp_server_v2: VoiceAssistantUDPServer, +) -> None: + """Test the UDP server with a v1/v2 device doesn't call _send_tts when the output is empty.""" + with patch( + "homeassistant.components.esphome.voice_assistant.VoiceAssistantUDPServer._send_tts" + ) as mock_send_tts: + voice_assistant_udp_server_v1._event_callback( + PipelineEvent(type=PipelineEventType.TTS_END, data={"tts_output": {}}) + ) + + mock_send_tts.assert_not_called() + + voice_assistant_udp_server_v2._event_callback( + PipelineEvent(type=PipelineEventType.TTS_END, data={"tts_output": {}}) + ) + + mock_send_tts.assert_not_called() + + async def test_send_tts( hass: HomeAssistant, voice_assistant_udp_server_v2: VoiceAssistantUDPServer, ) -> None: """Test the UDP server calls sendto to transmit audio data to device.""" + with io.BytesIO() as wav_io: + with wave.open(wav_io, "wb") as wav_file: + wav_file.setframerate(16000) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + wav_file.writeframes(bytes(_ONE_SECOND)) + + wav_bytes = wav_io.getvalue() + with patch( "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", - return_value=("raw", bytes(1024)), + return_value=("wav", wav_bytes), ): voice_assistant_udp_server_v2.transport = Mock(spec=asyncio.DatagramTransport) @@ -360,6 +393,63 @@ async def test_send_tts( voice_assistant_udp_server_v2.transport.sendto.assert_called() +async def test_send_tts_wrong_sample_rate( + hass: HomeAssistant, + voice_assistant_udp_server_v2: VoiceAssistantUDPServer, +) -> None: + """Test the UDP server calls sendto to transmit audio data to device.""" + with io.BytesIO() as wav_io: + with wave.open(wav_io, "wb") as wav_file: + wav_file.setframerate(22050) # should be 16000 + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + wav_file.writeframes(bytes(_ONE_SECOND)) + + wav_bytes = wav_io.getvalue() + + with patch( + "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", + return_value=("wav", wav_bytes), + ), pytest.raises(ValueError): + voice_assistant_udp_server_v2.transport = Mock(spec=asyncio.DatagramTransport) + + voice_assistant_udp_server_v2._event_callback( + PipelineEvent( + type=PipelineEventType.TTS_END, + data={ + "tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL} + }, + ) + ) + + assert voice_assistant_udp_server_v2._tts_task is not None + await voice_assistant_udp_server_v2._tts_task # raises ValueError + + +async def test_send_tts_wrong_format( + hass: HomeAssistant, + voice_assistant_udp_server_v2: VoiceAssistantUDPServer, +) -> None: + """Test that only WAV audio will be streamed.""" + with patch( + "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", + return_value=("raw", bytes(1024)), + ), pytest.raises(ValueError): + voice_assistant_udp_server_v2.transport = Mock(spec=asyncio.DatagramTransport) + + voice_assistant_udp_server_v2._event_callback( + PipelineEvent( + type=PipelineEventType.TTS_END, + data={ + "tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL} + }, + ) + ) + + assert voice_assistant_udp_server_v2._tts_task is not None + await voice_assistant_udp_server_v2._tts_task # raises ValueError + + async def test_wake_word( hass: HomeAssistant, voice_assistant_udp_server_v2: VoiceAssistantUDPServer, diff --git a/tests/components/evil_genius_labs/conftest.py b/tests/components/evil_genius_labs/conftest.py index 66dd8979d67..a4f10fe97c4 100644 --- a/tests/components/evil_genius_labs/conftest.py +++ b/tests/components/evil_genius_labs/conftest.py @@ -51,7 +51,8 @@ async def setup_evil_genius_labs( "pyevilgenius.EvilGeniusDevice.get_product", return_value=product_fixture, ), patch( - "homeassistant.components.evil_genius_labs.PLATFORMS", platforms + "homeassistant.components.evil_genius_labs.PLATFORMS", + platforms, ): assert await async_setup_component(hass, "evil_genius_labs", {}) await hass.async_block_till_done() diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py index 8338afc9c68..ec421141768 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -1,8 +1,19 @@ """Tests for fan platforms.""" import pytest -from homeassistant.components.fan import FanEntity +from homeassistant.components.fan import ( + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + DOMAIN, + SERVICE_SET_PRESET_MODE, + FanEntity, + NotValidPresetModeError, +) from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.testing_config.custom_components.test.fan import MockFan class BaseFan(FanEntity): @@ -82,3 +93,55 @@ def test_fanentity_attributes(attribute_name, attribute_value) -> None: fan = BaseFan() setattr(fan, f"_attr_{attribute_name}", attribute_value) assert getattr(fan, attribute_name) == attribute_value + + +async def test_preset_mode_validation( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + entity_registry: er.EntityRegistry, + enable_custom_integrations: None, +) -> None: + """Test preset mode validation.""" + + await hass.async_block_till_done() + + platform = getattr(hass.components, "test.fan") + platform.init(empty=False) + + assert await async_setup_component(hass, "fan", {"fan": {"platform": "test"}}) + await hass.async_block_till_done() + + test_fan: MockFan = platform.ENTITIES["support_preset_mode"] + await hass.async_block_till_done() + + state = hass.states.get("fan.support_fan_with_preset_mode_support") + assert state.attributes.get(ATTR_PRESET_MODES) == ["auto", "eco"] + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + { + "entity_id": "fan.support_fan_with_preset_mode_support", + "preset_mode": "eco", + }, + blocking=True, + ) + + state = hass.states.get("fan.support_fan_with_preset_mode_support") + assert state.attributes.get(ATTR_PRESET_MODE) == "eco" + + with pytest.raises(NotValidPresetModeError) as exc: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + { + "entity_id": "fan.support_fan_with_preset_mode_support", + "preset_mode": "invalid", + }, + blocking=True, + ) + assert exc.value.translation_key == "not_valid_preset_mode" + + with pytest.raises(NotValidPresetModeError) as exc: + await test_fan._valid_preset_mode_or_raise("invalid") + assert exc.value.translation_key == "not_valid_preset_mode" diff --git a/tests/components/fastdotcom/__init__.py b/tests/components/fastdotcom/__init__.py new file mode 100644 index 00000000000..4c2ca6301af --- /dev/null +++ b/tests/components/fastdotcom/__init__.py @@ -0,0 +1 @@ +"""Fast.com integration tests.""" diff --git a/tests/components/fastdotcom/test_config_flow.py b/tests/components/fastdotcom/test_config_flow.py new file mode 100644 index 00000000000..4314a7688d8 --- /dev/null +++ b/tests/components/fastdotcom/test_config_flow.py @@ -0,0 +1,74 @@ +"""Test for the Fast.com config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.fastdotcom.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_user_form(hass: HomeAssistant) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.fastdotcom.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Fast.com" + assert result["data"] == {} + assert result["options"] == {} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize("source", [SOURCE_USER, SOURCE_IMPORT]) +async def test_single_instance_allowed( + hass: HomeAssistant, + source: str, +) -> None: + """Test we abort if already setup.""" + mock_config_entry = MockConfigEntry(domain=DOMAIN) + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_import_flow_success(hass: HomeAssistant) -> None: + """Test import flow.""" + with patch( + "homeassistant.components.fastdotcom.__init__.SpeedtestData", + return_value={"download": "50"}, + ), patch("homeassistant.components.fastdotcom.sensor.SpeedtestSensor"): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Fast.com" + assert result["data"] == {} + assert result["options"] == {} diff --git a/tests/components/fibaro/test_config_flow.py b/tests/components/fibaro/test_config_flow.py index cb3d35d6f43..42d20f902c0 100644 --- a/tests/components/fibaro/test_config_flow.py +++ b/tests/components/fibaro/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Fibaro config flow.""" -from unittest.mock import Mock, patch +from unittest.mock import Mock import pytest from requests.exceptions import HTTPError @@ -10,52 +10,53 @@ from homeassistant.components.fibaro.config_flow import _normalize_url from homeassistant.components.fibaro.const import CONF_IMPORT_PLUGINS from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult, FlowResultType + +from .conftest import TEST_NAME, TEST_PASSWORD, TEST_URL, TEST_USERNAME from tests.common import MockConfigEntry -TEST_SERIALNUMBER = "HC2-111111" -TEST_NAME = "my_fibaro_home_center" -TEST_URL = "http://192.168.1.1/api/" -TEST_USERNAME = "user" -TEST_PASSWORD = "password" -TEST_VERSION = "4.360" - -pytestmark = pytest.mark.usefixtures("mock_setup_entry") +pytestmark = pytest.mark.usefixtures("mock_setup_entry", "mock_fibaro_client") -@pytest.fixture(name="fibaro_client", autouse=True) -def fibaro_client_fixture(): - """Mock common methods and attributes of fibaro client.""" - info_mock = Mock() - info_mock.return_value.serial_number = TEST_SERIALNUMBER - info_mock.return_value.hc_name = TEST_NAME - info_mock.return_value.current_version = TEST_VERSION +async def _recovery_after_failure_works( + hass: HomeAssistant, mock_fibaro_client: Mock, result: FlowResult +) -> None: + mock_fibaro_client.connect.side_effect = None + mock_fibaro_client.connect.return_value = True - client_mock = Mock() - client_mock.base_url.return_value = TEST_URL + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) - with patch( - "homeassistant.components.fibaro.FibaroClient.__init__", - return_value=None, - ), patch( - "homeassistant.components.fibaro.FibaroClient.read_info", - info_mock, - create=True, - ), patch( - "homeassistant.components.fibaro.FibaroClient.read_rooms", - return_value=[], - ), patch( - "homeassistant.components.fibaro.FibaroClient.read_devices", - return_value=[], - ), patch( - "homeassistant.components.fibaro.FibaroClient.read_scenes", - return_value=[], - ), patch( - "homeassistant.components.fibaro.FibaroClient._rest_client", - client_mock, - create=True, - ): - yield + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_IMPORT_PLUGINS: False, + } + + +async def _recovery_after_reauth_failure_works( + hass: HomeAssistant, mock_fibaro_client: Mock, result: FlowResult +) -> None: + mock_fibaro_client.connect.side_effect = None + mock_fibaro_client.connect.return_value = True + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "other_fake_password"}, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" async def test_config_flow_user_initiated_success(hass: HomeAssistant) -> None: @@ -64,270 +65,239 @@ async def test_config_flow_user_initiated_success(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} - with patch( - "homeassistant.components.fibaro.FibaroClient.connect", - return_value=True, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - - assert result["type"] == "create_entry" - assert result["title"] == TEST_NAME - assert result["data"] == { + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { CONF_URL: TEST_URL, CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, - CONF_IMPORT_PLUGINS: False, - } + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_IMPORT_PLUGINS: False, + } -async def test_config_flow_user_initiated_connect_failure(hass: HomeAssistant) -> None: +async def test_config_flow_user_initiated_connect_failure( + hass: HomeAssistant, mock_fibaro_client: Mock +) -> None: """Connect failure in flow manually initialized by the user.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} - with patch( - "homeassistant.components.fibaro.FibaroClient.connect", - return_value=False, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) + mock_fibaro_client.connect.return_value = False - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + await _recovery_after_failure_works(hass, mock_fibaro_client, result) -async def test_config_flow_user_initiated_auth_failure(hass: HomeAssistant) -> None: +async def test_config_flow_user_initiated_auth_failure( + hass: HomeAssistant, mock_fibaro_client: Mock +) -> None: """Authentication failure in flow manually initialized by the user.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} - login_mock = Mock() - login_mock.side_effect = HTTPError(response=Mock(status_code=403)) - with patch( - "homeassistant.components.fibaro.FibaroClient.connect", login_mock, create=True - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) + mock_fibaro_client.connect.side_effect = HTTPError(response=Mock(status_code=403)) - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_auth"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + await _recovery_after_failure_works(hass, mock_fibaro_client, result) async def test_config_flow_user_initiated_unknown_failure_1( - hass: HomeAssistant, + hass: HomeAssistant, mock_fibaro_client: Mock ) -> None: """Unknown failure in flow manually initialized by the user.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} - login_mock = Mock() - login_mock.side_effect = HTTPError(response=Mock(status_code=500)) - with patch( - "homeassistant.components.fibaro.FibaroClient.connect", login_mock, create=True - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) + mock_fibaro_client.connect.side_effect = HTTPError(response=Mock(status_code=500)) - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + await _recovery_after_failure_works(hass, mock_fibaro_client, result) async def test_config_flow_user_initiated_unknown_failure_2( - hass: HomeAssistant, + hass: HomeAssistant, mock_fibaro_client: Mock ) -> None: """Unknown failure in flow manually initialized by the user.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} - login_mock = Mock() - login_mock.side_effect = Exception() - with patch( - "homeassistant.components.fibaro.FibaroClient.connect", login_mock, create=True - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) + mock_fibaro_client.connect.side_effect = Exception() - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_reauth_success(hass: HomeAssistant) -> None: - """Successful reauth flow initialized by the user.""" - mock_config = MockConfigEntry( - domain=DOMAIN, - entry_id=TEST_SERIALNUMBER, - data={ + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { CONF_URL: TEST_URL, CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, - CONF_IMPORT_PLUGINS: False, }, ) - mock_config.add_to_hass(hass) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + await _recovery_after_failure_works(hass, mock_fibaro_client, result) + + +async def test_reauth_success( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Successful reauth flow initialized by the user.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={ "source": config_entries.SOURCE_REAUTH, - "entry_id": mock_config.entry_id, + "entry_id": mock_config_entry.entry_id, }, ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} - with patch( - "homeassistant.components.fibaro.FibaroClient.connect", return_value=True - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_PASSWORD: "other_fake_password"}, - ) - - assert result["type"] == "abort" - assert result["reason"] == "reauth_successful" - - -async def test_reauth_connect_failure(hass: HomeAssistant) -> None: - """Successful reauth flow initialized by the user.""" - mock_config = MockConfigEntry( - domain=DOMAIN, - entry_id=TEST_SERIALNUMBER, - data={ - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - CONF_IMPORT_PLUGINS: False, - }, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "other_fake_password"}, ) - mock_config.add_to_hass(hass) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_connect_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_fibaro_client: Mock, +) -> None: + """Successful reauth flow initialized by the user.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={ "source": config_entries.SOURCE_REAUTH, - "entry_id": mock_config.entry_id, + "entry_id": mock_config_entry.entry_id, }, ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} - login_mock = Mock() - login_mock.side_effect = Exception() - with patch( - "homeassistant.components.fibaro.FibaroClient.connect", login_mock, create=True - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_PASSWORD: "other_fake_password"}, - ) + mock_fibaro_client.connect.side_effect = Exception() - assert result["type"] == "form" - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_reauth_auth_failure(hass: HomeAssistant) -> None: - """Successful reauth flow initialized by the user.""" - mock_config = MockConfigEntry( - domain=DOMAIN, - entry_id=TEST_SERIALNUMBER, - data={ - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - CONF_IMPORT_PLUGINS: False, - }, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "other_fake_password"}, ) - mock_config.add_to_hass(hass) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "cannot_connect"} + + await _recovery_after_reauth_failure_works(hass, mock_fibaro_client, result) + + +async def test_reauth_auth_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_fibaro_client: Mock, +) -> None: + """Successful reauth flow initialized by the user.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={ "source": config_entries.SOURCE_REAUTH, - "entry_id": mock_config.entry_id, + "entry_id": mock_config_entry.entry_id, }, ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} - login_mock = Mock() - login_mock.side_effect = HTTPError(response=Mock(status_code=403)) - with patch( - "homeassistant.components.fibaro.FibaroClient.connect", login_mock, create=True - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_PASSWORD: "other_fake_password"}, - ) + mock_fibaro_client.connect.side_effect = HTTPError(response=Mock(status_code=403)) - assert result["type"] == "form" - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "invalid_auth"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "other_fake_password"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + await _recovery_after_reauth_failure_works(hass, mock_fibaro_client, result) @pytest.mark.parametrize("url_path", ["/api/", "/api", "/", ""]) diff --git a/tests/components/fibaro/test_scene.py b/tests/components/fibaro/test_scene.py index 0ce618e903c..e07face3ac0 100644 --- a/tests/components/fibaro/test_scene.py +++ b/tests/components/fibaro/test_scene.py @@ -13,6 +13,7 @@ from tests.common import MockConfigEntry async def test_entity_attributes( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_fibaro_client: Mock, mock_config_entry: MockConfigEntry, mock_scene: Mock, @@ -22,7 +23,6 @@ async def test_entity_attributes( # Arrange mock_fibaro_client.read_rooms.return_value = [mock_room] mock_fibaro_client.read_scenes.return_value = [mock_scene] - entity_registry = er.async_get(hass) # Act await init_integration(hass, mock_config_entry) # Assert @@ -35,6 +35,7 @@ async def test_entity_attributes( async def test_entity_attributes_without_room( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_fibaro_client: Mock, mock_config_entry: MockConfigEntry, mock_scene: Mock, @@ -45,7 +46,6 @@ async def test_entity_attributes_without_room( mock_room.name = None mock_fibaro_client.read_rooms.return_value = [mock_room] mock_fibaro_client.read_scenes.return_value = [mock_scene] - entity_registry = er.async_get(hass) # Act await init_integration(hass, mock_config_entry) # Assert diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index 26df432a270..1f93875a001 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -249,7 +249,9 @@ async def test_history_time(recorder_mock: Recorder, hass: HomeAssistant) -> Non assert state.state == "18.0" -async def test_setup(recorder_mock: Recorder, hass: HomeAssistant) -> None: +async def test_setup( + recorder_mock: Recorder, hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test if filter attributes are inherited.""" config = { "sensor": { @@ -284,8 +286,7 @@ async def test_setup(recorder_mock: Recorder, hass: HomeAssistant) -> None: assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.TOTAL_INCREASING assert state.state == "1.0" - entity_reg = er.async_get(hass) - entity_id = entity_reg.async_get_entity_id( + entity_id = entity_registry.async_get_entity_id( "sensor", DOMAIN, "uniqueid_sensor_test" ) assert entity_id == "sensor.test" diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index d14c7ae78da..871088eae63 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -241,7 +241,7 @@ async def test_sensors( ("devices_response", "monitored_resources"), [([DEVICE_RESPONSE_CHARGE_2, DEVICE_RESPONSE_ARIA_AIR], ["devices/battery"])], ) -async def test_device_battery_level( +async def test_device_battery( hass: HomeAssistant, fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], @@ -279,12 +279,48 @@ async def test_device_battery_level( "type": "scale", } - entity_registry = er.async_get(hass) entry = entity_registry.async_get("sensor.aria_air_battery") assert entry assert entry.unique_id == f"{PROFILE_USER_ID}_devices/battery_016713257" +@pytest.mark.parametrize( + ("devices_response", "monitored_resources"), + [([DEVICE_RESPONSE_CHARGE_2, DEVICE_RESPONSE_ARIA_AIR], ["devices/battery"])], +) +async def test_device_battery_level( + hass: HomeAssistant, + fitbit_config_setup: None, + sensor_platform_setup: Callable[[], Awaitable[bool]], + entity_registry: er.EntityRegistry, +) -> None: + """Test battery level sensor for devices.""" + + assert await sensor_platform_setup() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + state = hass.states.get("sensor.charge_2_battery_level") + assert state + assert state.state == "60" + assert state.attributes == { + "attribution": "Data provided by Fitbit.com", + "friendly_name": "Charge 2 Battery level", + "device_class": "battery", + "unit_of_measurement": "%", + } + + state = hass.states.get("sensor.aria_air_battery_level") + assert state + assert state.state == "95" + assert state.attributes == { + "attribution": "Data provided by Fitbit.com", + "friendly_name": "Aria Air Battery level", + "device_class": "battery", + "unit_of_measurement": "%", + } + + @pytest.mark.parametrize( ( "monitored_resources", @@ -558,6 +594,7 @@ async def test_settings_scope_config_entry( states = hass.states.async_all() assert [s.entity_id for s in states] == [ "sensor.charge_2_battery", + "sensor.charge_2_battery_level", ] diff --git a/tests/components/flic/test_binary_sensor.py b/tests/components/flic/test_binary_sensor.py index 41d2bf97c8e..2fa703348f9 100644 --- a/tests/components/flic/test_binary_sensor.py +++ b/tests/components/flic/test_binary_sensor.py @@ -29,7 +29,9 @@ class _MockFlicClient: self.channel = channel -async def test_button_uid(hass: HomeAssistant) -> None: +async def test_button_uid( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test UID assignment for Flic buttons.""" address_to_name = { "80:e4:da:78:6e:11": "binary_sensor.flic_80e4da786e11", @@ -53,7 +55,6 @@ async def test_button_uid(hass: HomeAssistant) -> None: await hass.async_block_till_done() - entity_registry = er.async_get(hass) for address, name in address_to_name.items(): state = hass.states.get(name) assert state diff --git a/tests/components/flux_led/test_init.py b/tests/components/flux_led/test_init.py index 969704edd18..7c709bafe73 100644 --- a/tests/components/flux_led/test_init.py +++ b/tests/components/flux_led/test_init.py @@ -139,7 +139,7 @@ async def test_config_entry_retry_right_away_on_discovery(hass: HomeAssistant) - async def test_coordinator_retry_right_away_on_discovery_already_setup( - hass: HomeAssistant, + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test discovery makes the coordinator force poll if its already setup.""" config_entry = MockConfigEntry( @@ -156,7 +156,6 @@ async def test_coordinator_retry_right_away_on_discovery_already_setup( assert config_entry.state == ConfigEntryState.LOADED entity_id = "light.bulb_rgbcw_ddeeff" - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == MAC_ADDRESS state = hass.states.get(entity_id) assert state.state == STATE_ON @@ -241,7 +240,9 @@ async def test_time_sync_startup_and_next_day(hass: HomeAssistant) -> None: assert len(bulb.async_set_time.mock_calls) == 2 -async def test_unique_id_migrate_when_mac_discovered(hass: HomeAssistant) -> None: +async def test_unique_id_migrate_when_mac_discovered( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test unique id migrated when mac discovered.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -260,7 +261,6 @@ async def test_unique_id_migrate_when_mac_discovered(hass: HomeAssistant) -> Non await hass.async_block_till_done() assert not config_entry.unique_id - entity_registry = er.async_get(hass) assert ( entity_registry.async_get("light.bulb_rgbcw_ddeeff").unique_id == config_entry.entry_id @@ -285,7 +285,7 @@ async def test_unique_id_migrate_when_mac_discovered(hass: HomeAssistant) -> Non async def test_unique_id_migrate_when_mac_discovered_via_discovery( - hass: HomeAssistant, + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test unique id migrated when mac discovered via discovery and the mac address from dhcp was one off.""" config_entry = MockConfigEntry( @@ -306,7 +306,6 @@ async def test_unique_id_migrate_when_mac_discovered_via_discovery( await hass.async_block_till_done() assert config_entry.unique_id == MAC_ADDRESS_ONE_OFF - entity_registry = er.async_get(hass) assert ( entity_registry.async_get("light.bulb_rgbcw_ddeeff").unique_id == MAC_ADDRESS_ONE_OFF diff --git a/tests/components/flux_led/test_light.py b/tests/components/flux_led/test_light.py index 171112c9097..974a029d143 100644 --- a/tests/components/flux_led/test_light.py +++ b/tests/components/flux_led/test_light.py @@ -81,7 +81,9 @@ from . import ( from tests.common import MockConfigEntry, async_fire_time_changed -async def test_light_unique_id(hass: HomeAssistant) -> None: +async def test_light_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a light unique id.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -95,13 +97,14 @@ async def test_light_unique_id(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "light.bulb_rgbcw_ddeeff" - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == MAC_ADDRESS state = hass.states.get(entity_id) assert state.state == STATE_ON -async def test_light_goes_unavailable_and_recovers(hass: HomeAssistant) -> None: +async def test_light_goes_unavailable_and_recovers( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a light goes unavailable and then recovers.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -115,7 +118,6 @@ async def test_light_goes_unavailable_and_recovers(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "light.bulb_rgbcw_ddeeff" - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == MAC_ADDRESS state = hass.states.get(entity_id) assert state.state == STATE_ON @@ -135,7 +137,9 @@ async def test_light_goes_unavailable_and_recovers(hass: HomeAssistant) -> None: assert state.state == STATE_ON -async def test_light_mac_address_not_found(hass: HomeAssistant) -> None: +async def test_light_mac_address_not_found( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a light when we cannot discover the mac address.""" config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE} @@ -147,7 +151,6 @@ async def test_light_mac_address_not_found(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "light.bulb_rgbcw_ddeeff" - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == config_entry.entry_id state = hass.states.get(entity_id) assert state.state == STATE_ON @@ -161,7 +164,12 @@ async def test_light_mac_address_not_found(hass: HomeAssistant) -> None: ], ) async def test_light_device_registry( - hass: HomeAssistant, protocol: str, sw_version: int, model_num: int, model: str + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + protocol: str, + sw_version: int, + model_num: int, + model: str, ) -> None: """Test a light device registry entry.""" config_entry = MockConfigEntry( @@ -180,7 +188,6 @@ async def test_light_device_registry( await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) await hass.async_block_till_done() - device_registry = dr.async_get(hass) device = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)} ) diff --git a/tests/components/flux_led/test_number.py b/tests/components/flux_led/test_number.py index ff288c777df..83bd0d1d517 100644 --- a/tests/components/flux_led/test_number.py +++ b/tests/components/flux_led/test_number.py @@ -41,7 +41,9 @@ from . import ( from tests.common import MockConfigEntry -async def test_effects_speed_unique_id(hass: HomeAssistant) -> None: +async def test_effects_speed_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a number unique id.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -55,11 +57,12 @@ async def test_effects_speed_unique_id(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "number.bulb_rgbcw_ddeeff_effect_speed" - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == MAC_ADDRESS -async def test_effects_speed_unique_id_no_discovery(hass: HomeAssistant) -> None: +async def test_effects_speed_unique_id_no_discovery( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a number unique id.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -72,7 +75,6 @@ async def test_effects_speed_unique_id_no_discovery(hass: HomeAssistant) -> None await hass.async_block_till_done() entity_id = "number.bulb_rgbcw_ddeeff_effect_speed" - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == config_entry.entry_id diff --git a/tests/components/flux_led/test_select.py b/tests/components/flux_led/test_select.py index 91be62e5ab7..c8fd64c6811 100644 --- a/tests/components/flux_led/test_select.py +++ b/tests/components/flux_led/test_select.py @@ -68,7 +68,9 @@ async def test_switch_power_restore_state(hass: HomeAssistant) -> None: ) -async def test_power_restored_unique_id(hass: HomeAssistant) -> None: +async def test_power_restored_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a select unique id.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -82,14 +84,15 @@ async def test_power_restored_unique_id(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "select.bulb_rgbcw_ddeeff_power_restored" - entity_registry = er.async_get(hass) assert ( entity_registry.async_get(entity_id).unique_id == f"{MAC_ADDRESS}_power_restored" ) -async def test_power_restored_unique_id_no_discovery(hass: HomeAssistant) -> None: +async def test_power_restored_unique_id_no_discovery( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a select unique id.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -102,7 +105,6 @@ async def test_power_restored_unique_id_no_discovery(hass: HomeAssistant) -> Non await hass.async_block_till_done() entity_id = "select.bulb_rgbcw_ddeeff_power_restored" - entity_registry = er.async_get(hass) assert ( entity_registry.async_get(entity_id).unique_id == f"{config_entry.entry_id}_power_restored" diff --git a/tests/components/flux_led/test_switch.py b/tests/components/flux_led/test_switch.py index cb0034f8d36..5d025a4cab0 100644 --- a/tests/components/flux_led/test_switch.py +++ b/tests/components/flux_led/test_switch.py @@ -71,7 +71,9 @@ async def test_switch_on_off(hass: HomeAssistant) -> None: assert hass.states.get(entity_id).state == STATE_ON -async def test_remote_access_unique_id(hass: HomeAssistant) -> None: +async def test_remote_access_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a remote access switch unique id.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -91,13 +93,14 @@ async def test_remote_access_unique_id(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "switch.bulb_rgbcw_ddeeff_remote_access" - entity_registry = er.async_get(hass) assert ( entity_registry.async_get(entity_id).unique_id == f"{MAC_ADDRESS}_remote_access" ) -async def test_effects_speed_unique_id_no_discovery(hass: HomeAssistant) -> None: +async def test_effects_speed_unique_id_no_discovery( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a remote access switch unique id when discovery fails.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -116,7 +119,6 @@ async def test_effects_speed_unique_id_no_discovery(hass: HomeAssistant) -> None await hass.async_block_till_done() entity_id = "switch.bulb_rgbcw_ddeeff_remote_access" - entity_registry = er.async_get(hass) assert ( entity_registry.async_get(entity_id).unique_id == f"{config_entry.entry_id}_remote_access" diff --git a/tests/components/forecast_solar/test_sensor.py b/tests/components/forecast_solar/test_sensor.py index 4539619febc..8faec950eb7 100644 --- a/tests/components/forecast_solar/test_sensor.py +++ b/tests/components/forecast_solar/test_sensor.py @@ -26,12 +26,12 @@ from tests.common import MockConfigEntry async def test_sensors( hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, init_integration: MockConfigEntry, ) -> None: """Test the Forecast.Solar sensors.""" entry_id = init_integration.entry_id - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) state = hass.states.get("sensor.energy_production_today") entry = entity_registry.async_get("sensor.energy_production_today") @@ -173,11 +173,12 @@ async def test_sensors( ), ) async def test_disabled_by_default( - hass: HomeAssistant, init_integration: MockConfigEntry, entity_id: str + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, + entity_id: str, ) -> None: """Test the Forecast.Solar sensors that are disabled by default.""" - entity_registry = er.async_get(hass) - state = hass.states.get(entity_id) assert state is None @@ -209,6 +210,7 @@ async def test_disabled_by_default( ) async def test_enabling_disable_by_default( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, mock_forecast_solar: MagicMock, key: str, @@ -218,7 +220,6 @@ async def test_enabling_disable_by_default( """Test the Forecast.Solar sensors that are disabled by default.""" entry_id = mock_config_entry.entry_id entity_id = f"{SENSOR_DOMAIN}.{key}" - entity_registry = er.async_get(hass) # Pre-create registry entry for disabled by default sensor entity_registry.async_get_or_create( diff --git a/tests/components/freebox/conftest.py b/tests/components/freebox/conftest.py index 63bc1d76d1a..3ba175cbc75 100644 --- a/tests/components/freebox/conftest.py +++ b/tests/components/freebox/conftest.py @@ -1,6 +1,8 @@ """Test helpers for Freebox.""" +import json from unittest.mock import AsyncMock, PropertyMock, patch +from freebox_api.exceptions import HttpRequestError import pytest from homeassistant.core import HomeAssistant @@ -10,12 +12,14 @@ from .const import ( DATA_CALL_GET_CALLS_LOG, DATA_CONNECTION_GET_STATUS, DATA_HOME_GET_NODES, - DATA_HOME_GET_VALUES, + DATA_HOME_PIR_GET_VALUE, + DATA_HOME_SET_VALUE, DATA_LAN_GET_HOSTS_LIST, + DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE, DATA_STORAGE_GET_DISKS, DATA_STORAGE_GET_RAIDS, DATA_SYSTEM_GET_CONFIG, - WIFI_GET_GLOBAL_CONFIG, + DATA_WIFI_GET_GLOBAL_CONFIG, ) from tests.common import MockConfigEntry @@ -24,7 +28,9 @@ from tests.common import MockConfigEntry @pytest.fixture(autouse=True) def mock_path(): """Mock path lib.""" - with patch("homeassistant.components.freebox.router.Path"): + with patch("homeassistant.components.freebox.router.Path"), patch( + "homeassistant.components.freebox.router.os.makedirs" + ): yield @@ -39,7 +45,9 @@ def enable_all_entities(): @pytest.fixture -def mock_device_registry_devices(hass: HomeAssistant, device_registry): +def mock_device_registry_devices( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +): """Create device registry devices so the device tracker entities are enabled.""" config_entry = MockConfigEntry(domain="something_else") config_entry.add_to_hass(hass) @@ -77,11 +85,30 @@ def mock_router(mock_device_registry_devices): return_value=DATA_CONNECTION_GET_STATUS ) # switch - instance.wifi.get_global_config = AsyncMock(return_value=WIFI_GET_GLOBAL_CONFIG) + instance.wifi.get_global_config = AsyncMock( + return_value=DATA_WIFI_GET_GLOBAL_CONFIG + ) # home devices instance.home.get_home_nodes = AsyncMock(return_value=DATA_HOME_GET_NODES) instance.home.get_home_endpoint_value = AsyncMock( - return_value=DATA_HOME_GET_VALUES + return_value=DATA_HOME_PIR_GET_VALUE + ) + instance.home.set_home_endpoint_value = AsyncMock( + return_value=DATA_HOME_SET_VALUE ) instance.close = AsyncMock() yield service_mock + + +@pytest.fixture(name="router_bridge_mode") +def mock_router_bridge_mode(mock_device_registry_devices, router): + """Mock a successful connection to Freebox Bridge mode.""" + + router().lan.get_hosts_list = AsyncMock( + side_effect=HttpRequestError( + "Request failed (APIResponse: %s)" + % json.dumps(DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE) + ) + ) + + return router diff --git a/tests/components/freebox/const.py b/tests/components/freebox/const.py index 788310bdbc0..ae07b39c5e8 100644 --- a/tests/components/freebox/const.py +++ b/tests/components/freebox/const.py @@ -1,2529 +1,50 @@ """Test constants.""" + +from tests.common import load_json_array_fixture, load_json_object_fixture + MOCK_HOST = "myrouter.freeboxos.fr" MOCK_PORT = 1234 # router -DATA_SYSTEM_GET_CONFIG = { - "mac": "68:A3:78:00:00:00", - "model_info": { - "has_ext_telephony": True, - "has_speakers_jack": True, - "wifi_type": "2d4_5g", - "pretty_name": "Freebox Server (r2)", - "customer_hdd_slots": 0, - "name": "fbxgw-r2/full", - "has_speakers": True, - "internal_hdd_size": 250, - "has_femtocell_exp": True, - "has_internal_hdd": True, - "has_dect": True, - }, - "fans": [{"id": "fan0_speed", "name": "Ventilateur 1", "value": 2130}], - "sensors": [ - {"id": "temp_hdd", "name": "Disque dur", "value": 40}, - {"id": "temp_hdd2", "name": "Disque dur 2"}, - {"id": "temp_sw", "name": "Température Switch", "value": 50}, - {"id": "temp_cpum", "name": "Température CPU M", "value": 60}, - {"id": "temp_cpub", "name": "Température CPU B", "value": 56}, - ], - "board_name": "fbxgw2r", - "disk_status": "active", - "uptime": "156 jours 19 heures 56 minutes 16 secondes", - "uptime_val": 13550176, - "user_main_storage": "Disque dur", - "box_authenticated": True, - "serial": "762601T190510709", - "firmware_version": "4.2.5", -} +DATA_SYSTEM_GET_CONFIG = load_json_object_fixture("freebox/system_get_config.json") # sensors -DATA_CONNECTION_GET_STATUS = { - "type": "ethernet", - "rate_down": 198900, - "bytes_up": 12035728872949, - "ipv4_port_range": [0, 65535], - "rate_up": 1440000, - "bandwidth_up": 700000000, - "ipv6": "2a01:e35:ffff:ffff::1", - "bandwidth_down": 1000000000, - "media": "ftth", - "state": "up", - "bytes_down": 2355966141297, - "ipv4": "82.67.00.00", -} +DATA_CONNECTION_GET_STATUS = load_json_object_fixture( + "freebox/connection_get_status.json" +) -DATA_CALL_GET_CALLS_LOG = [ - { - "number": "0988290475", - "type": "missed", - "id": 94, - "duration": 15, - "datetime": 1613752718, - "contact_id": 0, - "line_id": 0, - "name": "0988290475", - "new": True, - }, - { - "number": "0367250217", - "type": "missed", - "id": 93, - "duration": 25, - "datetime": 1613662328, - "contact_id": 0, - "line_id": 0, - "name": "0367250217", - "new": True, - }, - { - "number": "0184726018", - "type": "missed", - "id": 92, - "duration": 25, - "datetime": 1613225098, - "contact_id": 0, - "line_id": 0, - "name": "0184726018", - "new": True, - }, -] +DATA_CALL_GET_CALLS_LOG = load_json_array_fixture("freebox/call_get_calls_log.json") -DATA_STORAGE_GET_DISKS = [ - { - "idle_duration": 0, - "read_error_requests": 0, - "read_requests": 1815106, - "spinning": True, - "table_type": "raid", - "firmware": "0001", - "type": "sata", - "idle": True, - "connector": 2, - "id": 1000, - "write_error_requests": 0, - "time_before_spindown": 600, - "state": "disabled", - "write_requests": 80386151, - "total_bytes": 2000000000000, - "model": "ST2000LM015-2E8174", - "active_duration": 0, - "temp": 30, - "serial": "ZDZLBFHC", - "partitions": [ - { - "fstype": "raid", - "total_bytes": 0, - "label": "Volume 2000Go", - "id": 1000, - "internal": False, - "fsck_result": "no_run_yet", - "state": "umounted", - "disk_id": 1000, - "free_bytes": 0, - "used_bytes": 0, - "path": "L1ZvbHVtZSAyMDAwR28=", - } - ], - }, - { - "idle_duration": 0, - "read_error_requests": 0, - "read_requests": 3622038, - "spinning": True, - "table_type": "raid", - "firmware": "0001", - "type": "sata", - "idle": True, - "connector": 0, - "id": 2000, - "write_error_requests": 0, - "time_before_spindown": 600, - "state": "disabled", - "write_requests": 80386151, - "total_bytes": 2000000000000, - "model": "ST2000LM015-2E8174", - "active_duration": 0, - "temp": 31, - "serial": "ZDZLEJXE", - "partitions": [ - { - "fstype": "raid", - "total_bytes": 0, - "label": "Volume 2000Go 1", - "id": 2000, - "internal": False, - "fsck_result": "no_run_yet", - "state": "umounted", - "disk_id": 2000, - "free_bytes": 0, - "used_bytes": 0, - "path": "L1ZvbHVtZSAyMDAwR28gMQ==", - } - ], - }, - { - "idle_duration": 0, - "read_error_requests": 0, - "read_requests": 0, - "spinning": False, - "table_type": "superfloppy", - "firmware": "", - "type": "raid", - "idle": False, - "connector": 0, - "id": 3000, - "write_error_requests": 0, - "state": "enabled", - "write_requests": 0, - "total_bytes": 2000000000000, - "model": "", - "active_duration": 0, - "temp": 0, - "serial": "", - "partitions": [ - { - "fstype": "ext4", - "total_bytes": 1960000000000, - "label": "Freebox", - "id": 3000, - "internal": False, - "fsck_result": "no_run_yet", - "state": "mounted", - "disk_id": 3000, - "free_bytes": 1730000000000, - "used_bytes": 236910000000, - "path": "L0ZyZWVib3g=", - } - ], - }, -] +DATA_STORAGE_GET_DISKS = load_json_array_fixture("freebox/storage_get_disks.json") -DATA_STORAGE_GET_RAIDS = [ - { - "degraded": False, - "raid_disks": 2, # Number of members that should be in this array - "next_check": 0, # Unix timestamp of next check in seconds. Might be 0 if check_interval is 0 - "sync_action": "idle", # values: idle, resync, recover, check, repair, reshape, frozen - "level": "raid1", # values: basic, raid0, raid1, raid5, raid10 - "uuid": "dc8679f8-13f9-11ee-9106-38d547790df8", - "sysfs_state": "clear", # values: clear, inactive, suspended, readonly, read_auto, clean, active, write_pending, active_idle - "id": 0, - "sync_completed_pos": 0, # Current position of sync process - "members": [ - { - "total_bytes": 2000000000000, - "active_device": 1, - "id": 1000, - "corrected_read_errors": 0, - "array_id": 0, - "disk": { - "firmware": "0001", - "temp": 29, - "serial": "ZDZLBFHC", - "model": "ST2000LM015-2E8174", - }, - "role": "active", # values: active, faulty, spare, missing - "sct_erc_supported": False, - "sct_erc_enabled": False, - "dev_uuid": "fca8720e-13f9-11ee-9106-38d547790df8", - "device_location": "sata-internal-p2", - "set_name": "Freebox", - "set_uuid": "dc8679f8-13f9-11ee-9106-38d547790df8", - }, - { - "total_bytes": 2000000000000, - "active_device": 0, - "id": 2000, - "corrected_read_errors": 0, - "array_id": 0, - "disk": { - "firmware": "0001", - "temp": 30, - "serial": "ZDZLEJXE", - "model": "ST2000LM015-2E8174", - }, - "role": "active", - "sct_erc_supported": False, - "sct_erc_enabled": False, - "dev_uuid": "16bf00d6-13fa-11ee-9106-38d547790df8", - "device_location": "sata-internal-p0", - "set_name": "Freebox", - "set_uuid": "dc8679f8-13f9-11ee-9106-38d547790df8", - }, - ], - "array_size": 2000000000000, # Size of array in bytes - "state": "running", # stopped, running, error - "sync_speed": 0, # Sync speed in bytes per second - "name": "Freebox", - "check_interval": 0, # Check interval in seconds - "disk_id": 3000, - "last_check": 1682884357, # Unix timestamp of last check in seconds - "sync_completed_end": 0, # End position of sync process: total of bytes to sync - "sync_completed_percent": 0, # Percentage of sync completion - } -] +DATA_STORAGE_GET_RAIDS = load_json_array_fixture("freebox/storage_get_raids.json") # switch -WIFI_GET_GLOBAL_CONFIG = {"enabled": True, "mac_filter_state": "disabled"} +DATA_WIFI_GET_GLOBAL_CONFIG = load_json_object_fixture( + "freebox/wifi_get_global_config.json" +) # device_tracker -DATA_LAN_GET_HOSTS_LIST = [ - { - "l2ident": {"id": "8C:97:EA:00:00:00", "type": "mac_address"}, - "active": True, - "persistent": False, - "names": [ - {"name": "d633d0c8-958c-43cc-e807-d881b076924b", "source": "mdns"}, - {"name": "Freebox Player POP", "source": "mdns_srv"}, - ], - "vendor_name": "Freebox SAS", - "host_type": "smartphone", - "interface": "pub", - "id": "ether-8c:97:ea:00:00:00", - "last_time_reachable": 1614107652, - "primary_name_manual": False, - "l3connectivities": [ - { - "addr": "192.168.1.180", - "active": True, - "reachable": True, - "last_activity": 1614107614, - "af": "ipv4", - "last_time_reachable": 1614104242, - }, - { - "addr": "fe80::dcef:dbba:6604:31d1", - "active": True, - "reachable": True, - "last_activity": 1614107645, - "af": "ipv6", - "last_time_reachable": 1614107645, - }, - { - "addr": "2a01:e34:eda1:eb40:8102:4704:7ce0:2ace", - "active": False, - "reachable": False, - "last_activity": 1611574428, - "af": "ipv6", - "last_time_reachable": 1611574428, - }, - { - "addr": "2a01:e34:eda1:eb40:c8e5:c524:c96d:5f5e", - "active": False, - "reachable": False, - "last_activity": 1612475101, - "af": "ipv6", - "last_time_reachable": 1612475101, - }, - { - "addr": "2a01:e34:eda1:eb40:583a:49df:1df0:c2df", - "active": True, - "reachable": True, - "last_activity": 1614107652, - "af": "ipv6", - "last_time_reachable": 1614107652, - }, - { - "addr": "2a01:e34:eda1:eb40:147e:3569:86ab:6aaa", - "active": False, - "reachable": False, - "last_activity": 1612486752, - "af": "ipv6", - "last_time_reachable": 1612486752, - }, - ], - "default_name": "Freebox Player POP", - "model": "fbx8am", - "reachable": True, - "last_activity": 1614107652, - "primary_name": "Freebox Player POP", - }, - { - "l2ident": {"id": "DE:00:B0:00:00:00", "type": "mac_address"}, - "active": False, - "persistent": False, - "vendor_name": "", - "host_type": "workstation", - "interface": "pub", - "id": "ether-de:00:b0:00:00:00", - "last_time_reachable": 1607125599, - "primary_name_manual": False, - "default_name": "", - "l3connectivities": [ - { - "addr": "192.168.1.181", - "active": False, - "reachable": False, - "last_activity": 1607125599, - "af": "ipv4", - "last_time_reachable": 1607125599, - }, - { - "addr": "192.168.1.182", - "active": False, - "reachable": False, - "last_activity": 1605958758, - "af": "ipv4", - "last_time_reachable": 1605958758, - }, - { - "addr": "2a01:e34:eda1:eb40:dc00:b0ff:fedf:e30", - "active": False, - "reachable": False, - "last_activity": 1607125594, - "af": "ipv6", - "last_time_reachable": 1607125594, - }, - ], - "reachable": False, - "last_activity": 1607125599, - "primary_name": "", - }, - { - "l2ident": {"id": "DC:00:B0:00:00:00", "type": "mac_address"}, - "active": True, - "persistent": False, - "names": [ - {"name": "Repeteur-Wifi-Freebox", "source": "mdns"}, - {"name": "Repeteur Wifi Freebox", "source": "mdns_srv"}, - ], - "vendor_name": "", - "host_type": "freebox_wifi", - "interface": "pub", - "id": "ether-dc:00:b0:00:00:00", - "last_time_reachable": 1614107678, - "primary_name_manual": False, - "l3connectivities": [ - { - "addr": "192.168.1.145", - "active": True, - "reachable": True, - "last_activity": 1614107678, - "af": "ipv4", - "last_time_reachable": 1614107678, - }, - { - "addr": "fe80::de00:b0ff:fe52:6ef6", - "active": True, - "reachable": True, - "last_activity": 1614107608, - "af": "ipv6", - "last_time_reachable": 1614107603, - }, - { - "addr": "2a01:e34:eda1:eb40:de00:b0ff:fe52:6ef6", - "active": True, - "reachable": True, - "last_activity": 1614107618, - "af": "ipv6", - "last_time_reachable": 1614107618, - }, - ], - "default_name": "Repeteur Wifi Freebox", - "model": "fbxwmr", - "reachable": True, - "last_activity": 1614107678, - "primary_name": "Repeteur Wifi Freebox", - }, - { - "l2ident": {"id": "5E:65:55:00:00:00", "type": "mac_address"}, - "active": False, - "persistent": False, - "names": [ - {"name": "iPhoneofQuentin", "source": "dhcp"}, - {"name": "iPhone-of-Quentin", "source": "mdns"}, - ], - "vendor_name": "", - "host_type": "smartphone", - "interface": "pub", - "id": "ether-5e:65:55:00:00:00", - "last_time_reachable": 1612611982, - "primary_name_manual": False, - "default_name": "iPhonedeQuentin", - "l3connectivities": [ - { - "addr": "192.168.1.148", - "active": False, - "reachable": False, - "last_activity": 1612611973, - "af": "ipv4", - "last_time_reachable": 1612611973, - }, - { - "addr": "fe80::14ca:6c30:938b:e281", - "active": False, - "reachable": False, - "last_activity": 1609693223, - "af": "ipv6", - "last_time_reachable": 1609693223, - }, - { - "addr": "fe80::1c90:2b94:1ba2:bd8b", - "active": False, - "reachable": False, - "last_activity": 1610797303, - "af": "ipv6", - "last_time_reachable": 1610797303, - }, - { - "addr": "fe80::8c8:e58b:838e:6785", - "active": False, - "reachable": False, - "last_activity": 1612611951, - "af": "ipv6", - "last_time_reachable": 1612611946, - }, - { - "addr": "2a01:e34:eda1:eb40:f0e7:e198:3a69:58", - "active": False, - "reachable": False, - "last_activity": 1609693245, - "af": "ipv6", - "last_time_reachable": 1609693245, - }, - { - "addr": "2a01:e34:eda1:eb40:1dc4:c6f8:aa20:c83b", - "active": False, - "reachable": False, - "last_activity": 1610797176, - "af": "ipv6", - "last_time_reachable": 1610797176, - }, - { - "addr": "2a01:e34:eda1:eb40:6cf6:5811:1770:c662", - "active": False, - "reachable": False, - "last_activity": 1612611982, - "af": "ipv6", - "last_time_reachable": 1612611982, - }, - { - "addr": "2a01:e34:eda1:eb40:438:9b2c:4f8f:f48a", - "active": False, - "reachable": False, - "last_activity": 1612611946, - "af": "ipv6", - "last_time_reachable": 1612611946, - }, - ], - "reachable": False, - "last_activity": 1612611982, - "primary_name": "iPhoneofQuentin", - }, -] - -# Home -# PIR node id 26, endpoint id 6 -DATA_HOME_GET_VALUES = { - "category": "", - "ep_type": "signal", - "id": 6, - "label": "Détection", - "name": "trigger", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", -} +DATA_LAN_GET_HOSTS_LIST = load_json_array_fixture("freebox/lan_get_hosts_list.json") +DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE = load_json_object_fixture( + "freebox/lan_get_hosts_list_bridge.json" +) # Home # ALL -DATA_HOME_GET_NODES = [ - { - "adapter": 2, - "area": 38, - "category": "camera", - "group": {"label": "Salon"}, - "id": 16, - "label": "Caméra II", - "name": "node_16", - "props": { - "Ip": "192.169.0.2", - "Login": "camfreebox", - "Mac": "34:2d:f2:e5:9d:ff", - "Pass": "xxxxx", - "Stream": "http://freeboxcam:mv...tream.m3u8", - }, - "show_endpoints": [ - { - "category": "", - "ep_type": "slot", - "id": 0, - "label": "Détection", - "name": "detection", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 1, - "label": "Activé avec l'alarme", - "name": "activation", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 2, - "label": "Haute qualité vidéo", - "name": "quality", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 3, - "label": "Sensibilité", - "name": "sensitivity", - "ui": {"access": "rw", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 4, - "label": "Seuil", - "name": "threshold", - "ui": {"access": "rw", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 5, - "label": "Retourner verticalement", - "name": "flip", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 6, - "label": "Horodatage", - "name": "timestamp", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 7, - "label": "Volume du micro", - "name": "volume", - "ui": {"access": "w", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 9, - "label": "Détection de bruit", - "name": "sound_detection", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 10, - "label": "Sensibilité du micro", - "name": "sound_trigger", - "ui": {"access": "w", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 11, - "label": "Flux rtsp", - "name": "rtsp", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 12, - "label": "Emplacement des vidéos", - "name": "disk", - "ui": {"access": "rw", "display": "disk"}, - "value": "", - "value_type": "string", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 13, - "label": "Détection ", - "name": "detection", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/xxxx.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 14, - "label": "Activé avec l'alarme", - "name": "activation", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Ce réglage permet d'activer l'enregistrement de la caméra uniquement quand l'alarme est activée.", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/alert_toggle.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 15, - "label": "Haute qualité vidéo", - "name": "quality", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Les vidéos seront enregistrées en 720p en haute qualité et 480p en qualité réduite.\r\n\r\nNous vous recommandons de laisser cette option désactivée si vous avez des difficultés de lecture des fichiers à distance.", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/Flux.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 16, - "label": "Sensibilité", - "name": "sensitivity", - "refresh": 2000, - "ui": { - "access": "r", - "description": "La sensibilité définit la faculté d'un pixel à être sensible aux changements.\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute, plus les déclenchements seront fréquents.", - "display": "slider", - "range": [...], - }, - "value": 3, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 17, - "label": "Seuil", - "name": "threshold", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Le seuil définit le nombre de pixels devant changer pour déclencher la détection .\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute moins les déclenchements seront fréquents.", - "display": "slider", - "range": [...], - }, - "value": 2, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 18, - "label": "Retourner verticalement", - "name": "flip", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/Retour.png", - }, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 19, - "label": "Horodatage", - "name": "timestamp", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/Horloge.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 20, - "label": "Volume du micro", - "name": "volume", - "refresh": 2000, - "ui": { - "access": "r", - "display": "slider", - "icon_url": "/resources/images/home/pictos/commande_vocale.png", - "range": [...], - }, - "value": 100, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 22, - "label": "Détection de bruit", - "name": "sound_detection", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/commande_vocale.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 23, - "label": "Sensibilité du micro", - "name": "sound_trigger", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Quatre réglages sont disponibles pour la sensibilité du micro (1-4).\r\n\r\nPlus cette valeur est haute, plus les enregistrements seront fréquents.", - "display": "slider", - "range": [...], - }, - "value": 3, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 24, - "label": "Flux rtsp", - "name": "rtsp", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Active le flux RTSP à l'adresse rtsp://ip_camera/live", - "display": "toggle", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 25, - "label": "Niveau de réception", - "name": "rssi", - "refresh": 2000, - "ui": { - "access": "r", - "display": "icon", - "icon_range": [...], - "icon_url": "/resources/images/home/pictos/reception_%.png", - "range": [...], - "status_text_range": [...], - "unit": "dB", - }, - "value": -75, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 26, - "label": "Emplacement des vidéos", - "name": "disk", - "refresh": 2000, - "ui": { - "access": "r", - "display": "disk", - "icon_url": "/resources/images/home/pictos/directory.png", - }, - "value": "Freebox", - "value_type": "string", - "visibility": "normal", - }, - ], - "signal_links": [], - "slot_links": [{...}], - "status": "active", - "type": { - "abstract": False, - "endpoints": [ - { - "category": "", - "ep_type": "slot", - "id": 0, - "label": "Détection ", - "name": "detection", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 1, - "label": "Activé avec l'alarme", - "name": "activation", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 2, - "label": "Haute qualité vidéo", - "name": "quality", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 3, - "label": "Sensibilité", - "name": "sensitivity", - "ui": {"access": "rw", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 4, - "label": "Seuil", - "name": "threshold", - "ui": {"access": "rw", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 5, - "label": "Retourner verticalement", - "name": "flip", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 6, - "label": "Horodatage", - "name": "timestamp", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 7, - "label": "Volume du micro", - "name": "volume", - "ui": {"access": "w", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 9, - "label": "Détection de bruit", - "name": "sound_detection", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 10, - "label": "Sensibilité du micro", - "name": "sound_trigger", - "ui": {"access": "w", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 11, - "label": "Flux rtsp", - "name": "rtsp", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 12, - "label": "Emplacement des vidéos", - "name": "disk", - "ui": {"access": "rw", "display": "disk"}, - "value": "", - "value_type": "string", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 13, - "label": "Détection ", - "name": "detection", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/xxxx.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 14, - "label": "Activé avec l'alarme", - "name": "activation", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Ce réglage permet d'activer l'enregistrement de la caméra uniquement quand l'alarme est activée.", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/alert_toggle.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 15, - "label": "Haute qualité vidéo", - "name": "quality", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Les vidéos seront enregistrées en 720p en haute qualité et 480p en qualité réduite.\r\n\r\nNous vous recommandons de laisser cette option désactivée si vous avez des difficultés de lecture des fichiers à distance.", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/Flux.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 16, - "label": "Sensibilité", - "name": "sensitivity", - "refresh": 2000, - "ui": { - "access": "r", - "description": "La sensibilité définit la faculté d'un pixel à être sensible aux changements.\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute, plus les déclenchements seront fréquents.", - "display": "slider", - "range": [...], - }, - "value": 3, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 17, - "label": "Seuil", - "name": "threshold", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Le seuil définit le nombre de pixels devant changer pour déclencher la détection .\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute moins les déclenchements seront fréquents.", - "display": "slider", - "range": [...], - }, - "value": 2, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 18, - "label": "Retourner verticalement", - "name": "flip", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/Retour.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 19, - "label": "Horodatage", - "name": "timestamp", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/Horloge.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 20, - "label": "Volume du micro", - "name": "volume", - "refresh": 2000, - "ui": { - "access": "r", - "display": "slider", - "icon_url": "/resources/images/home/pictos/commande_vocale.png", - "range": [...], - }, - "value": 80, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 22, - "label": "Détection de bruit", - "name": "sound_detection", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/commande_vocale.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 23, - "label": "Sensibilité du micro", - "name": "sound_trigger", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Quatre réglages sont disponibles pour la sensibilité du micro (1-4).\r\n\r\nPlus cette valeur est haute, plus les enregistrements seront fréquents.", - "display": "slider", - "range": [...], - }, - "value": 3, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 24, - "label": "Flux rtsp", - "name": "rtsp", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Active le flux RTSP à l'adresse rtsp://ip_camera/live", - "display": "toggle", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 25, - "label": "Niveau de réception", - "name": "rssi", - "refresh": 2000, - "ui": { - "access": "r", - "display": "icon", - "icon_range": [...], - "icon_url": "/resources/images/home/pictos/reception_%.png", - "range": [...], - "status_text_range": [...], - "unit": "dB", - }, - "value": -49, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 26, - "label": "Emplacement des vidéos", - "name": "disk", - "refresh": 2000, - "ui": { - "access": "r", - "display": "disk", - "icon_url": "/resources/images/home/pictos/directory.png", - }, - "value": "Freebox", - "value_type": "string", - "visibility": "normal", - }, - ], - "generic": False, - "icon": "/resources/images/ho...camera.png", - "inherit": "node::cam", - "label": "Caméra Freebox", - "name": "node::cam::freebox", - "params": {}, - "physical": True, - }, - }, - { - "adapter": 1, - "area": 38, - "category": "camera", - "group": {"label": "Salon"}, - "id": 15, - "label": "Caméra I", - "name": "node_15", - "props": { - "Ip": "192.169.0.2", - "Login": "camfreebox", - "Mac": "34:2d:f2:e5:9d:ff", - "Pass": "xxxxx", - "Stream": "http://freeboxcam:mv...tream.m3u8", - }, - "show_endpoints": [ - { - "category": "", - "ep_type": "slot", - "id": 0, - "label": "Détection ", - "name": "detection", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 1, - "label": "Activé avec l'alarme", - "name": "activation", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 2, - "label": "Haute qualité vidéo", - "name": "quality", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 3, - "label": "Sensibilité", - "name": "sensitivity", - "ui": {"access": "rw", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 4, - "label": "Seuil", - "name": "threshold", - "ui": {"access": "rw", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 5, - "label": "Retourner verticalement", - "name": "flip", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 6, - "label": "Horodatage", - "name": "timestamp", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 7, - "label": "Volume du micro", - "name": "volume", - "ui": {"access": "w", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 9, - "label": "Détection de bruit", - "name": "sound_detection", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 10, - "label": "Sensibilité du micro", - "name": "sound_trigger", - "ui": {"access": "w", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 11, - "label": "Flux rtsp", - "name": "rtsp", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 12, - "label": "Emplacement des vidéos", - "name": "disk", - "ui": {"access": "rw", "display": "disk"}, - "value": "", - "value_type": "string", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 13, - "label": "Détection ", - "name": "detection", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/xxxx.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 14, - "label": "Activé avec l'alarme", - "name": "activation", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Ce réglage permet d'activer l'enregistrement de la caméra uniquement quand l'alarme est activée.", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/alert_toggle.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 15, - "label": "Haute qualité vidéo", - "name": "quality", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Les vidéos seront enregistrées en 720p en haute qualité et 480p en qualité réduite.\r\n\r\nNous vous recommandons de laisser cette option désactivée si vous avez des difficultés de lecture des fichiers à distance.", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/Flux.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 16, - "label": "Sensibilité", - "name": "sensitivity", - "refresh": 2000, - "ui": { - "access": "r", - "description": "La sensibilité définit la faculté d'un pixel à être sensible aux changements.\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute, plus les déclenchements seront fréquents.", - "display": "slider", - "range": [...], - }, - "value": 3, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 17, - "label": "Seuil", - "name": "threshold", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Le seuil définit le nombre de pixels devant changer pour déclencher la détection .\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute moins les déclenchements seront fréquents.", - "display": "slider", - "range": [...], - }, - "value": 2, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 18, - "label": "Retourner verticalement", - "name": "flip", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/Retour.png", - }, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 19, - "label": "Horodatage", - "name": "timestamp", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/Horloge.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 20, - "label": "Volume du micro", - "name": "volume", - "refresh": 2000, - "ui": { - "access": "r", - "display": "slider", - "icon_url": "/resources/images/home/pictos/commande_vocale.png", - "range": [...], - }, - "value": 100, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 22, - "label": "Détection de bruit", - "name": "sound_detection", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/commande_vocale.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 23, - "label": "Sensibilité du micro", - "name": "sound_trigger", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Quatre réglages sont disponibles pour la sensibilité du micro (1-4).\r\n\r\nPlus cette valeur est haute, plus les enregistrements seront fréquents.", - "display": "slider", - "range": [...], - }, - "value": 3, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 24, - "label": "Flux rtsp", - "name": "rtsp", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Active le flux RTSP à l'adresse rtsp://ip_camera/live", - "display": "toggle", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 25, - "label": "Niveau de réception", - "name": "rssi", - "refresh": 2000, - "ui": { - "access": "r", - "display": "icon", - "icon_range": [...], - "icon_url": "/resources/images/home/pictos/reception_%.png", - "range": [...], - "status_text_range": [...], - "unit": "dB", - }, - "value": -75, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 26, - "label": "Emplacement des vidéos", - "name": "disk", - "refresh": 2000, - "ui": { - "access": "r", - "display": "disk", - "icon_url": "/resources/images/home/pictos/directory.png", - }, - "value": "Freebox", - "value_type": "string", - "visibility": "normal", - }, - ], - "signal_links": [], - "slot_links": [{...}], - "status": "active", - "type": { - "abstract": False, - "endpoints": [ - { - "category": "", - "ep_type": "slot", - "id": 0, - "label": "Détection ", - "name": "detection", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 1, - "label": "Activé avec l'alarme", - "name": "activation", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 2, - "label": "Haute qualité vidéo", - "name": "quality", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 3, - "label": "Sensibilité", - "name": "sensitivity", - "ui": {"access": "rw", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 4, - "label": "Seuil", - "name": "threshold", - "ui": {"access": "rw", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 5, - "label": "Retourner verticalement", - "name": "flip", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 6, - "label": "Horodatage", - "name": "timestamp", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 7, - "label": "Volume du micro", - "name": "volume", - "ui": {"access": "w", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 9, - "label": "Détection de bruit", - "name": "sound_detection", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 10, - "label": "Sensibilité du micro", - "name": "sound_trigger", - "ui": {"access": "w", "display": "slider", "range": [...]}, - "value": 0, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 11, - "label": "Flux rtsp", - "name": "rtsp", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 12, - "label": "Emplacement des vidéos", - "name": "disk", - "ui": {"access": "rw", "display": "disk"}, - "value": "", - "value_type": "string", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 13, - "label": "Détection ", - "name": "detection", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/xxxx.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 14, - "label": "Activé avec l'alarme", - "name": "activation", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Ce réglage permet d'activer l'enregistrement de la caméra uniquement quand l'alarme est activée.", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/alert_toggle.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 15, - "label": "Haute qualité vidéo", - "name": "quality", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Les vidéos seront enregistrées en 720p en haute qualité et 480p en qualité réduite.\r\n\r\nNous vous recommandons de laisser cette option désactivée si vous avez des difficultés de lecture des fichiers à distance.", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/Flux.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 16, - "label": "Sensibilité", - "name": "sensitivity", - "refresh": 2000, - "ui": { - "access": "r", - "description": "La sensibilité définit la faculté d'un pixel à être sensible aux changements.\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute, plus les déclenchements seront fréquents.", - "display": "slider", - "range": [...], - }, - "value": 3, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 17, - "label": "Seuil", - "name": "threshold", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Le seuil définit le nombre de pixels devant changer pour déclencher la détection .\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute moins les déclenchements seront fréquents.", - "display": "slider", - "range": [...], - }, - "value": 2, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 18, - "label": "Retourner verticalement", - "name": "flip", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/Retour.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 19, - "label": "Horodatage", - "name": "timestamp", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/Horloge.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 20, - "label": "Volume du micro", - "name": "volume", - "refresh": 2000, - "ui": { - "access": "r", - "display": "slider", - "icon_url": "/resources/images/home/pictos/commande_vocale.png", - "range": [...], - }, - "value": 80, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 22, - "label": "Détection de bruit", - "name": "sound_detection", - "refresh": 2000, - "ui": { - "access": "r", - "display": "toggle", - "icon_url": "/resources/images/home/pictos/commande_vocale.png", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 23, - "label": "Sensibilité du micro", - "name": "sound_trigger", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Quatre réglages sont disponibles pour la sensibilité du micro (1-4).\r\n\r\nPlus cette valeur est haute, plus les enregistrements seront fréquents.", - "display": "slider", - "range": [...], - }, - "value": 3, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 24, - "label": "Flux rtsp", - "name": "rtsp", - "refresh": 2000, - "ui": { - "access": "r", - "description": "Active le flux RTSP à l'adresse rtsp://ip_camera/live", - "display": "toggle", - }, - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 25, - "label": "Niveau de réception", - "name": "rssi", - "refresh": 2000, - "ui": { - "access": "r", - "display": "icon", - "icon_range": [...], - "icon_url": "/resources/images/home/pictos/reception_%.png", - "range": [...], - "status_text_range": [...], - "unit": "dB", - }, - "value": -49, - "value_type": "int", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 26, - "label": "Emplacement des vidéos", - "name": "disk", - "refresh": 2000, - "ui": { - "access": "r", - "display": "disk", - "icon_url": "/resources/images/home/pictos/directory.png", - }, - "value": "Freebox", - "value_type": "string", - "visibility": "normal", - }, - ], - "generic": False, - "icon": "/resources/images/ho...camera.png", - "inherit": "node::cam", - "label": "Caméra Freebox", - "name": "node::cam::freebox", - "params": {}, - "physical": True, - }, - }, - { - "adapter": 5, - "category": "kfb", - "group": {"label": ""}, - "id": 9, - "label": "Télécommande", - "name": "node_9", - "props": { - "Address": 5, - "Challenge": "65ae6b4def41f3e3a5a77ec63e988", - "FwVersion": 29798318, - "Gateway": 1, - "ItemId": "e76c2b75a4a6e2", - }, - "show_endpoints": [ - { - "category": "", - "ep_type": "slot", - "id": 0, - "label": "Activé", - "name": "enable", - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 1, - "label": "Activé", - "name": "enable", - "value": True, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 2, - "label": "Bouton appuyé", - "name": "pushed", - "value": None, - "value_type": "int", - }, - { - "category": "", - "ep_type": "signal", - "id": 3, - "label": "Niveau de Batterie", - "name": "battery", - "refresh": 2000, - "value": 100, - "value_type": "int", - }, - ], - "signal_links": [ - { - "adapter": 5, - "category": "alarm", - "id": 7, - "label": "Système d alarme", - "link_id": 10, - "name": "node_7", - "status": "active", - }, - ], - "slot_links": [], - "status": "active", - "type": { - "abstract": False, - "endpoints": [...], - "generic": False, - "icon": "/resources/images/home/pictos/telecommande.png", - "inherit": "node::domus", - "label": "Télécommande pour alarme", - "name": "node::domus::sercomm::keyfob", - "params": {}, - "physical": True, - }, - }, - { - "adapter": 5, - "area": 40, - "category": "dws", - "group": {"label": "Entrée"}, - "id": 11, - "label": "Ouverture porte", - "name": "node_11", - "props": { - "Address": 6, - "Challenge": "964a2dddf2c40c3e2384f66d2", - "FwVersion": 29798220, - "Gateway": 1, - "ItemId": "9eff759dd553de7", - }, - "show_endpoints": [ - { - "category": "", - "ep_type": "slot", - "id": 0, - "label": "Alarme principale", - "name": "alarm1", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 1, - "label": "Alarme secondaire", - "name": "alarm2", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 2, - "label": "Zone temporisée", - "name": "timed", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 7, - "label": "Couvercle", - "name": "cover", - "refresh": 2000, - "ui": { - "access": "r", - "display": "warning", - "icon_url": "/resources/images/home/pictos/warning.png", - }, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 8, - "label": "Niveau de Batterie", - "name": "battery", - "refresh": 2000, - "ui": { - "access": "r", - "display": "icon", - "icon_range": [...], - "icon_url": "/resources/images/home/pictos/batt_%.png", - "range": [...], - "status_text_range": [...], - "unit": "%", - }, - "value": 100, - "value_type": "int", - "visibility": "normal", - }, - ], - "signal_links": [ - { - "adapter": 5, - "category": "alarm", - "id": 7, - "label": "Système d alarme", - "link_id": 12, - "name": "node_7", - "status": "active", - } - ], - "slot_links": [], - "status": "active", - "type": { - "abstract": False, - "endpoints": [ - { - "ep_type": "slot", - "id": 0, - "label": "Alarme principale", - "name": "alarm1", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "slot", - "id": 1, - "label": "Alarme secondaire", - "name": "alarm2", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "slot", - "id": 2, - "label": "Zone temporisée", - "name": "timed", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 3, - "label": "Alarme principale", - "name": "alarm1", - "param_type": "void", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 4, - "label": "Alarme secondaire", - "name": "alarm2", - "param_type": "void", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 5, - "label": "Zone temporisée", - "name": "timed", - "param_type": "void", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 6, - "label": "Détection", - "name": "trigger", - "param_type": "void", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 7, - "label": "Couvercle", - "name": "cover", - "param_type": "void", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 8, - "label": "Niveau de Batterie", - "name": "1battery", - "param_type": "void", - "value_type": "int", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 9, - "label": "Batterie faible", - "name": "battery_warning", - "param_type": "void", - "value_type": "int", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 10, - "label": "Alarme", - "name": "alarm", - "param_type": "void", - "value_type": "void", - "visibility": "internal", - }, - ], - "generic": False, - "icon": "/resources/images/home/pictos/detecteur_ouverture.png", - "inherit": "node::domus", - "label": "Détecteur d'ouverture de porte", - "name": "node::domus::sercomm::doorswitch", - "params": {}, - "physical": True, - }, - }, - { - "adapter": 5, - "area": 38, - "category": "pir", - "group": {"label": "Salon"}, - "id": 26, - "label": "Détecteur", - "name": "node_26", - "props": { - "Address": 9, - "Challenge": "ed2cc17f179862f5242256b3f597c367", - "FwVersion": 29871925, - "Gateway": 1, - "ItemId": "240d000f9fefe576", - }, - "show_endpoints": [ - { - "category": "", - "ep_type": "slot", - "id": 0, - "label": "Alarme principale", - "name": "alarm1", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 1, - "label": "Alarme secondaire", - "name": "alarm2", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "slot", - "id": 2, - "label": "Zone temporisée", - "name": "timed", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 6, - "label": "Détection", - "name": "trigger", - "ui": {"access": "w", "display": "toggle"}, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 7, - "label": "Couvercle", - "name": "cover", - "refresh": 2000, - "ui": { - "access": "r", - "display": "warning", - "icon_url": "/resources/images/home/pictos/warning.png", - }, - "value": False, - "value_type": "bool", - "visibility": "normal", - }, - { - "category": "", - "ep_type": "signal", - "id": 8, - "label": "Niveau de Batterie", - "name": "battery", - "refresh": 2000, - "ui": { - "access": "r", - "display": "icon", - "icon_range": [...], - "icon_url": "/resources/images/home/pictos/batt_x.png", - "status_text_range": [...], - "unit": "%", - }, - "value": 100, - "value_type": "int", - "visibility": "normal", - }, - ], - "signal_links": [ - { - "adapter": 5, - "category": "alarm", - "id": 7, - "label": "Système d alarme", - "link_id": 12, - "name": "node_7", - "status": "active", - } - ], - "slot_links": [], - "status": "active", - "type": { - "abstract": False, - "endpoints": [ - { - "ep_type": "slot", - "id": 0, - "label": "Alarme principale", - "name": "alarm1", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "slot", - "id": 1, - "label": "Alarme secondaire", - "name": "alarm2", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "slot", - "id": 2, - "label": "Zone temporisée", - "name": "timed", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 3, - "label": "Alarme principale", - "name": "alarm1", - "param_type": "void", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 4, - "label": "Alarme secondaire", - "name": "alarm2", - "param_type": "void", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 5, - "label": "Zone temporisée", - "name": "timed", - "param_type": "void", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 6, - "label": "Détection", - "name": "trigger", - "param_type": "void", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 7, - "label": "Couvercle", - "name": "cover", - "param_type": "void", - "value_type": "bool", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 8, - "label": "Niveau de Batterie", - "name": "battery", - "param_type": "void", - "value_type": "int", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 9, - "label": "Batterie faible", - "name": "battery_warning", - "param_type": "void", - "value_type": "int", - "visibility": "normal", - }, - { - "ep_type": "signal", - "id": 10, - "label": "Alarme", - "name": "alarm", - "param_type": "void", - "value_type": "void", - "visibility": "internal", - }, - ], - "generic": False, - "icon": "/resources/images/home/pictos/detecteur_xxxx.png", - "inherit": "node::domus", - "label": "Détecteur infrarouge", - "name": "node::domus::sercomm::pir", - "params": {}, - "physical": True, - }, - }, - { - "adapter": 10, - "area": 38, - "category": "shutter", - "group": {"label": "Salon"}, - "id": 150, - "label": "Shutter 1", - "name": "node_150", - "type": { - "inherit": "node::trs", - }, - }, - { - "adapter": 11, - "area": 38, - "category": "shutter", - "group": {"label": "Salon"}, - "id": 151, - "label": "Shutter 2", - "name": "node_151", - "type": { - "inherit": "node::ios", - }, - }, -] +DATA_HOME_GET_NODES = load_json_array_fixture("freebox/home_get_nodes.json") + +# Home +# PIR node id 26, endpoint id 6 +DATA_HOME_PIR_GET_VALUE = load_json_object_fixture("freebox/home_pir_get_value.json") + +# Home +# ALARM node id 7, endpoint id 11 +DATA_HOME_ALARM_GET_VALUE = load_json_object_fixture( + "freebox/home_alarm_get_value.json" +) + +# Home +# Set a node value with success +DATA_HOME_SET_VALUE = load_json_object_fixture("freebox/home_set_value.json") diff --git a/tests/components/freebox/fixtures/call_get_calls_log.json b/tests/components/freebox/fixtures/call_get_calls_log.json new file mode 100644 index 00000000000..4ee641dc0c5 --- /dev/null +++ b/tests/components/freebox/fixtures/call_get_calls_log.json @@ -0,0 +1,35 @@ +[ + { + "number": "0988290475", + "type": "missed", + "id": 94, + "duration": 15, + "datetime": 1613752718, + "contact_id": 0, + "line_id": 0, + "name": "0988290475", + "new": true + }, + { + "number": "0367250217", + "type": "missed", + "id": 93, + "duration": 25, + "datetime": 1613662328, + "contact_id": 0, + "line_id": 0, + "name": "0367250217", + "new": true + }, + { + "number": "0184726018", + "type": "missed", + "id": 92, + "duration": 25, + "datetime": 1613225098, + "contact_id": 0, + "line_id": 0, + "name": "0184726018", + "new": true + } +] diff --git a/tests/components/freebox/fixtures/connection_get_status.json b/tests/components/freebox/fixtures/connection_get_status.json new file mode 100644 index 00000000000..362ac71edf0 --- /dev/null +++ b/tests/components/freebox/fixtures/connection_get_status.json @@ -0,0 +1,14 @@ +{ + "type": "ethernet", + "rate_down": 198900, + "bytes_up": 12035728872949, + "ipv4_port_range": [0, 65535], + "rate_up": 1440000, + "bandwidth_up": 700000000, + "ipv6": "2a01:e35:ffff:ffff::1", + "bandwidth_down": 1000000000, + "media": "ftth", + "state": "up", + "bytes_down": 2355966141297, + "ipv4": "82.67.00.00" +} diff --git a/tests/components/freebox/fixtures/home_alarm_get_value.json b/tests/components/freebox/fixtures/home_alarm_get_value.json new file mode 100644 index 00000000000..6e4ad4d0538 --- /dev/null +++ b/tests/components/freebox/fixtures/home_alarm_get_value.json @@ -0,0 +1,5 @@ +{ + "refresh": 2000, + "value": "alarm1_armed", + "value_type": "string" +} diff --git a/tests/components/freebox/fixtures/home_get_nodes.json b/tests/components/freebox/fixtures/home_get_nodes.json new file mode 100644 index 00000000000..b72505279b2 --- /dev/null +++ b/tests/components/freebox/fixtures/home_get_nodes.json @@ -0,0 +1,2545 @@ +[ + { + "adapter": 2, + "area": 38, + "category": "camera", + "group": { + "label": "Salon" + }, + "id": 16, + "label": "Caméra II", + "name": "node_16", + "props": { + "Ip": "192.169.0.2", + "Login": "camfreebox", + "Mac": "34:2d:f2:e5:9d:ff", + "Pass": "xxxxx", + "Stream": "http://freeboxcam:mv...tream.m3u8" + }, + "show_endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 0, + "label": "Détection", + "name": "detection", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 1, + "label": "Activé avec l'alarme", + "name": "activation", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 2, + "label": "Haute qualité vidéo", + "name": "quality", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 3, + "label": "Sensibilité", + "name": "sensitivity", + "ui": { + "access": "rw", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 4, + "label": "Seuil", + "name": "threshold", + "ui": { + "access": "rw", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 5, + "label": "Retourner verticalement", + "name": "flip", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 6, + "label": "Horodatage", + "name": "timestamp", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 7, + "label": "Volume du micro", + "name": "volume", + "ui": { + "access": "w", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 9, + "label": "Détection de bruit", + "name": "sound_detection", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 10, + "label": "Sensibilité du micro", + "name": "sound_trigger", + "ui": { + "access": "w", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 11, + "label": "Flux rtsp", + "name": "rtsp", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 12, + "label": "Emplacement des vidéos", + "name": "disk", + "ui": { + "access": "rw", + "display": "disk" + }, + "value": "", + "value_type": "string", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 13, + "label": "Détection ", + "name": "detection", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/xxxx.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 14, + "label": "Activé avec l'alarme", + "name": "activation", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Ce réglage permet d'activer l'enregistrement de la caméra uniquement quand l'alarme est activée.", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/alert_toggle.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 15, + "label": "Haute qualité vidéo", + "name": "quality", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Les vidéos seront enregistrées en 720p en haute qualité et 480p en qualité réduite.\r\n\r\nNous vous recommandons de laisser cette option désactivée si vous avez des difficultés de lecture des fichiers à distance.", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Flux.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 16, + "label": "Sensibilité", + "name": "sensitivity", + "refresh": 2000, + "ui": { + "access": "r", + "description": "La sensibilité définit la faculté d'un pixel à être sensible aux changements.\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute, plus les déclenchements seront fréquents.", + "display": "slider", + "range": [] + }, + "value": 3, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 17, + "label": "Seuil", + "name": "threshold", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Le seuil définit le nombre de pixels devant changer pour déclencher la détection .\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute moins les déclenchements seront fréquents.", + "display": "slider", + "range": [] + }, + "value": 2, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 18, + "label": "Retourner verticalement", + "name": "flip", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Retour.png" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 19, + "label": "Horodatage", + "name": "timestamp", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Horloge.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 20, + "label": "Volume du micro", + "name": "volume", + "refresh": 2000, + "ui": { + "access": "r", + "display": "slider", + "icon_url": "/resources/images/home/pictos/commande_vocale.png", + "range": [] + }, + "value": 100, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 22, + "label": "Détection de bruit", + "name": "sound_detection", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/commande_vocale.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 23, + "label": "Sensibilité du micro", + "name": "sound_trigger", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Quatre réglages sont disponibles pour la sensibilité du micro (1-4).\r\n\r\nPlus cette valeur est haute, plus les enregistrements seront fréquents.", + "display": "slider", + "range": [] + }, + "value": 3, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 24, + "label": "Flux rtsp", + "name": "rtsp", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Active le flux RTSP à l'adresse rtsp://ip_camera/live", + "display": "toggle" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 25, + "label": "Niveau de réception", + "name": "rssi", + "refresh": 2000, + "ui": { + "access": "r", + "display": "icon", + "icon_range": [], + "icon_url": "/resources/images/home/pictos/reception_%.png", + "range": [], + "status_text_range": [], + "unit": "dB" + }, + "value": -75, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 26, + "label": "Emplacement des vidéos", + "name": "disk", + "refresh": 2000, + "ui": { + "access": "r", + "display": "disk", + "icon_url": "/resources/images/home/pictos/directory.png" + }, + "value": "Freebox", + "value_type": "string", + "visibility": "normal" + } + ], + "signal_links": [], + "slot_links": [{}], + "status": "active", + "type": { + "abstract": false, + "endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 0, + "label": "Détection ", + "name": "detection", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 1, + "label": "Activé avec l'alarme", + "name": "activation", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 2, + "label": "Haute qualité vidéo", + "name": "quality", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 3, + "label": "Sensibilité", + "name": "sensitivity", + "ui": { + "access": "rw", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 4, + "label": "Seuil", + "name": "threshold", + "ui": { + "access": "rw", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 5, + "label": "Retourner verticalement", + "name": "flip", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 6, + "label": "Horodatage", + "name": "timestamp", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 7, + "label": "Volume du micro", + "name": "volume", + "ui": { + "access": "w", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 9, + "label": "Détection de bruit", + "name": "sound_detection", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 10, + "label": "Sensibilité du micro", + "name": "sound_trigger", + "ui": { + "access": "w", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 11, + "label": "Flux rtsp", + "name": "rtsp", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 12, + "label": "Emplacement des vidéos", + "name": "disk", + "ui": { + "access": "rw", + "display": "disk" + }, + "value": "", + "value_type": "string", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 13, + "label": "Détection ", + "name": "detection", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/xxxx.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 14, + "label": "Activé avec l'alarme", + "name": "activation", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Ce réglage permet d'activer l'enregistrement de la caméra uniquement quand l'alarme est activée.", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/alert_toggle.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 15, + "label": "Haute qualité vidéo", + "name": "quality", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Les vidéos seront enregistrées en 720p en haute qualité et 480p en qualité réduite.\r\n\r\nNous vous recommandons de laisser cette option désactivée si vous avez des difficultés de lecture des fichiers à distance.", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Flux.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 16, + "label": "Sensibilité", + "name": "sensitivity", + "refresh": 2000, + "ui": { + "access": "r", + "description": "La sensibilité définit la faculté d'un pixel à être sensible aux changements.\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute, plus les déclenchements seront fréquents.", + "display": "slider", + "range": [] + }, + "value": 3, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 17, + "label": "Seuil", + "name": "threshold", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Le seuil définit le nombre de pixels devant changer pour déclencher la détection .\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute moins les déclenchements seront fréquents.", + "display": "slider", + "range": [] + }, + "value": 2, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 18, + "label": "Retourner verticalement", + "name": "flip", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Retour.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 19, + "label": "Horodatage", + "name": "timestamp", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Horloge.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 20, + "label": "Volume du micro", + "name": "volume", + "refresh": 2000, + "ui": { + "access": "r", + "display": "slider", + "icon_url": "/resources/images/home/pictos/commande_vocale.png", + "range": [] + }, + "value": 80, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 22, + "label": "Détection de bruit", + "name": "sound_detection", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/commande_vocale.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 23, + "label": "Sensibilité du micro", + "name": "sound_trigger", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Quatre réglages sont disponibles pour la sensibilité du micro (1-4).\r\n\r\nPlus cette valeur est haute, plus les enregistrements seront fréquents.", + "display": "slider", + "range": [] + }, + "value": 3, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 24, + "label": "Flux rtsp", + "name": "rtsp", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Active le flux RTSP à l'adresse rtsp://ip_camera/live", + "display": "toggle" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 25, + "label": "Niveau de réception", + "name": "rssi", + "refresh": 2000, + "ui": { + "access": "r", + "display": "icon", + "icon_range": [], + "icon_url": "/resources/images/home/pictos/reception_%.png", + "range": [], + "status_text_range": [], + "unit": "dB" + }, + "value": -49, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 26, + "label": "Emplacement des vidéos", + "name": "disk", + "refresh": 2000, + "ui": { + "access": "r", + "display": "disk", + "icon_url": "/resources/images/home/pictos/directory.png" + }, + "value": "Freebox", + "value_type": "string", + "visibility": "normal" + } + ], + "generic": false, + "icon": "/resources/images/ho...camera.png", + "inherit": "node::cam", + "label": "Caméra Freebox", + "name": "node::cam::freebox", + "params": {}, + "physical": true + } + }, + { + "adapter": 1, + "area": 38, + "category": "camera", + "group": { + "label": "Salon" + }, + "id": 15, + "label": "Caméra I", + "name": "node_15", + "props": { + "Ip": "192.169.0.2", + "Login": "camfreebox", + "Mac": "34:2d:f2:e5:9d:ff", + "Pass": "xxxxx", + "Stream": "http://freeboxcam:mv...tream.m3u8" + }, + "show_endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 0, + "label": "Détection ", + "name": "detection", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 1, + "label": "Activé avec l'alarme", + "name": "activation", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 2, + "label": "Haute qualité vidéo", + "name": "quality", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 3, + "label": "Sensibilité", + "name": "sensitivity", + "ui": { + "access": "rw", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 4, + "label": "Seuil", + "name": "threshold", + "ui": { + "access": "rw", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 5, + "label": "Retourner verticalement", + "name": "flip", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 6, + "label": "Horodatage", + "name": "timestamp", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 7, + "label": "Volume du micro", + "name": "volume", + "ui": { + "access": "w", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 9, + "label": "Détection de bruit", + "name": "sound_detection", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 10, + "label": "Sensibilité du micro", + "name": "sound_trigger", + "ui": { + "access": "w", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 11, + "label": "Flux rtsp", + "name": "rtsp", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 12, + "label": "Emplacement des vidéos", + "name": "disk", + "ui": { + "access": "rw", + "display": "disk" + }, + "value": "", + "value_type": "string", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 13, + "label": "Détection ", + "name": "detection", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/xxxx.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 14, + "label": "Activé avec l'alarme", + "name": "activation", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Ce réglage permet d'activer l'enregistrement de la caméra uniquement quand l'alarme est activée.", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/alert_toggle.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 15, + "label": "Haute qualité vidéo", + "name": "quality", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Les vidéos seront enregistrées en 720p en haute qualité et 480p en qualité réduite.\r\n\r\nNous vous recommandons de laisser cette option désactivée si vous avez des difficultés de lecture des fichiers à distance.", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Flux.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 16, + "label": "Sensibilité", + "name": "sensitivity", + "refresh": 2000, + "ui": { + "access": "r", + "description": "La sensibilité définit la faculté d'un pixel à être sensible aux changements.\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute, plus les déclenchements seront fréquents.", + "display": "slider", + "range": [] + }, + "value": 3, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 17, + "label": "Seuil", + "name": "threshold", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Le seuil définit le nombre de pixels devant changer pour déclencher la détection .\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute moins les déclenchements seront fréquents.", + "display": "slider", + "range": [] + }, + "value": 2, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 18, + "label": "Retourner verticalement", + "name": "flip", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Retour.png" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 19, + "label": "Horodatage", + "name": "timestamp", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Horloge.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 20, + "label": "Volume du micro", + "name": "volume", + "refresh": 2000, + "ui": { + "access": "r", + "display": "slider", + "icon_url": "/resources/images/home/pictos/commande_vocale.png", + "range": [] + }, + "value": 100, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 22, + "label": "Détection de bruit", + "name": "sound_detection", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/commande_vocale.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 23, + "label": "Sensibilité du micro", + "name": "sound_trigger", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Quatre réglages sont disponibles pour la sensibilité du micro (1-4).\r\n\r\nPlus cette valeur est haute, plus les enregistrements seront fréquents.", + "display": "slider", + "range": [] + }, + "value": 3, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 24, + "label": "Flux rtsp", + "name": "rtsp", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Active le flux RTSP à l'adresse rtsp://ip_camera/live", + "display": "toggle" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 25, + "label": "Niveau de réception", + "name": "rssi", + "refresh": 2000, + "ui": { + "access": "r", + "display": "icon", + "icon_range": [], + "icon_url": "/resources/images/home/pictos/reception_%.png", + "range": [], + "status_text_range": [], + "unit": "dB" + }, + "value": -75, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 26, + "label": "Emplacement des vidéos", + "name": "disk", + "refresh": 2000, + "ui": { + "access": "r", + "display": "disk", + "icon_url": "/resources/images/home/pictos/directory.png" + }, + "value": "Freebox", + "value_type": "string", + "visibility": "normal" + } + ], + "signal_links": [], + "slot_links": [{}], + "status": "active", + "type": { + "abstract": false, + "endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 0, + "label": "Détection ", + "name": "detection", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 1, + "label": "Activé avec l'alarme", + "name": "activation", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 2, + "label": "Haute qualité vidéo", + "name": "quality", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 3, + "label": "Sensibilité", + "name": "sensitivity", + "ui": { + "access": "rw", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 4, + "label": "Seuil", + "name": "threshold", + "ui": { + "access": "rw", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 5, + "label": "Retourner verticalement", + "name": "flip", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 6, + "label": "Horodatage", + "name": "timestamp", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 7, + "label": "Volume du micro", + "name": "volume", + "ui": { + "access": "w", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 9, + "label": "Détection de bruit", + "name": "sound_detection", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 10, + "label": "Sensibilité du micro", + "name": "sound_trigger", + "ui": { + "access": "w", + "display": "slider", + "range": [] + }, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 11, + "label": "Flux rtsp", + "name": "rtsp", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 12, + "label": "Emplacement des vidéos", + "name": "disk", + "ui": { + "access": "rw", + "display": "disk" + }, + "value": "", + "value_type": "string", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 13, + "label": "Détection ", + "name": "detection", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/xxxx.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 14, + "label": "Activé avec l'alarme", + "name": "activation", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Ce réglage permet d'activer l'enregistrement de la caméra uniquement quand l'alarme est activée.", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/alert_toggle.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 15, + "label": "Haute qualité vidéo", + "name": "quality", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Les vidéos seront enregistrées en 720p en haute qualité et 480p en qualité réduite.\r\n\r\nNous vous recommandons de laisser cette option désactivée si vous avez des difficultés de lecture des fichiers à distance.", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Flux.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 16, + "label": "Sensibilité", + "name": "sensitivity", + "refresh": 2000, + "ui": { + "access": "r", + "description": "La sensibilité définit la faculté d'un pixel à être sensible aux changements.\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute, plus les déclenchements seront fréquents.", + "display": "slider", + "range": [] + }, + "value": 3, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 17, + "label": "Seuil", + "name": "threshold", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Le seuil définit le nombre de pixels devant changer pour déclencher la détection .\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute moins les déclenchements seront fréquents.", + "display": "slider", + "range": [] + }, + "value": 2, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 18, + "label": "Retourner verticalement", + "name": "flip", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Retour.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 19, + "label": "Horodatage", + "name": "timestamp", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Horloge.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 20, + "label": "Volume du micro", + "name": "volume", + "refresh": 2000, + "ui": { + "access": "r", + "display": "slider", + "icon_url": "/resources/images/home/pictos/commande_vocale.png", + "range": [] + }, + "value": 80, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 22, + "label": "Détection de bruit", + "name": "sound_detection", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/commande_vocale.png" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 23, + "label": "Sensibilité du micro", + "name": "sound_trigger", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Quatre réglages sont disponibles pour la sensibilité du micro (1-4).\r\n\r\nPlus cette valeur est haute, plus les enregistrements seront fréquents.", + "display": "slider", + "range": [] + }, + "value": 3, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 24, + "label": "Flux rtsp", + "name": "rtsp", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Active le flux RTSP à l'adresse rtsp://ip_camera/live", + "display": "toggle" + }, + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 25, + "label": "Niveau de réception", + "name": "rssi", + "refresh": 2000, + "ui": { + "access": "r", + "display": "icon", + "icon_range": [], + "icon_url": "/resources/images/home/pictos/reception_%.png", + "range": [], + "status_text_range": [], + "unit": "dB" + }, + "value": -49, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 26, + "label": "Emplacement des vidéos", + "name": "disk", + "refresh": 2000, + "ui": { + "access": "r", + "display": "disk", + "icon_url": "/resources/images/home/pictos/directory.png" + }, + "value": "Freebox", + "value_type": "string", + "visibility": "normal" + } + ], + "generic": false, + "icon": "/resources/images/ho...camera.png", + "inherit": "node::cam", + "label": "Caméra Freebox", + "name": "node::cam::freebox", + "params": {}, + "physical": true + } + }, + { + "adapter": 5, + "category": "kfb", + "group": { + "label": "" + }, + "id": 9, + "label": "Télécommande", + "name": "node_9", + "props": { + "Address": 5, + "Challenge": "65ae6b4def41f3e3a5a77ec63e988", + "FwVersion": 29798318, + "Gateway": 1, + "ItemId": "e76c2b75a4a6e2" + }, + "show_endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 0, + "label": "Activé", + "name": "enable", + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 1, + "label": "Activé", + "name": "enable", + "value": true, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 2, + "label": "Bouton appuyé", + "name": "pushed", + "value": null, + "value_type": "int" + }, + { + "category": "", + "ep_type": "signal", + "id": 3, + "label": "Niveau de Batterie", + "name": "battery", + "refresh": 2000, + "value": 100, + "value_type": "int" + } + ], + "signal_links": [ + { + "adapter": 5, + "category": "alarm", + "id": 7, + "label": "Système d alarme", + "link_id": 10, + "name": "node_7", + "status": "active" + } + ], + "slot_links": [], + "status": "active", + "type": { + "abstract": false, + "endpoints": [], + "generic": false, + "icon": "/resources/images/home/pictos/telecommande.png", + "inherit": "node::domus", + "label": "Télécommande pour alarme", + "name": "node::domus::sercomm::keyfob", + "params": {}, + "physical": true + } + }, + { + "adapter": 5, + "area": 40, + "category": "dws", + "group": { + "label": "Entrée" + }, + "id": 11, + "label": "Ouverture porte", + "name": "node_11", + "props": { + "Address": 6, + "Challenge": "964a2dddf2c40c3e2384f66d2", + "FwVersion": 29798220, + "Gateway": 1, + "ItemId": "9eff759dd553de7" + }, + "show_endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 0, + "label": "Alarme principale", + "name": "alarm1", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 1, + "label": "Alarme secondaire", + "name": "alarm2", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 2, + "label": "Zone temporisée", + "name": "timed", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 7, + "label": "Couvercle", + "name": "cover", + "refresh": 2000, + "ui": { + "access": "r", + "display": "warning", + "icon_url": "/resources/images/home/pictos/warning.png" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 8, + "label": "Niveau de Batterie", + "name": "battery", + "refresh": 2000, + "ui": { + "access": "r", + "display": "icon", + "icon_range": [], + "icon_url": "/resources/images/home/pictos/batt_%.png", + "range": [], + "status_text_range": [], + "unit": "%" + }, + "value": 100, + "value_type": "int", + "visibility": "normal" + } + ], + "signal_links": [ + { + "adapter": 5, + "category": "alarm", + "id": 7, + "label": "Système d alarme", + "link_id": 12, + "name": "node_7", + "status": "active" + } + ], + "slot_links": [], + "status": "active", + "type": { + "abstract": false, + "endpoints": [ + { + "ep_type": "slot", + "id": 0, + "label": "Alarme principale", + "name": "alarm1", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "slot", + "id": 1, + "label": "Alarme secondaire", + "name": "alarm2", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "slot", + "id": 2, + "label": "Zone temporisée", + "name": "timed", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 3, + "label": "Alarme principale", + "name": "alarm1", + "param_type": "void", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 4, + "label": "Alarme secondaire", + "name": "alarm2", + "param_type": "void", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 5, + "label": "Zone temporisée", + "name": "timed", + "param_type": "void", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 6, + "label": "Détection", + "name": "trigger", + "param_type": "void", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 7, + "label": "Couvercle", + "name": "cover", + "param_type": "void", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 8, + "label": "Niveau de Batterie", + "name": "1battery", + "param_type": "void", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 9, + "label": "Batterie faible", + "name": "battery_warning", + "param_type": "void", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 10, + "label": "Alarme", + "name": "alarm", + "param_type": "void", + "value_type": "void", + "visibility": "internal" + } + ], + "generic": false, + "icon": "/resources/images/home/pictos/detecteur_ouverture.png", + "inherit": "node::domus", + "label": "Détecteur d'ouverture de porte", + "name": "node::domus::sercomm::doorswitch", + "params": {}, + "physical": true + } + }, + { + "adapter": 5, + "area": 38, + "category": "pir", + "group": { + "label": "Salon" + }, + "id": 26, + "label": "Détecteur", + "name": "node_26", + "props": { + "Address": 9, + "Challenge": "ed2cc17f179862f5242256b3f597c367", + "FwVersion": 29871925, + "Gateway": 1, + "ItemId": "240d000f9fefe576" + }, + "show_endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 0, + "label": "Alarme principale", + "name": "alarm1", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 1, + "label": "Alarme secondaire", + "name": "alarm2", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 2, + "label": "Zone temporisée", + "name": "timed", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 6, + "label": "Détection", + "name": "trigger", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 7, + "label": "Couvercle", + "name": "cover", + "refresh": 2000, + "ui": { + "access": "r", + "display": "warning", + "icon_url": "/resources/images/home/pictos/warning.png" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 8, + "label": "Niveau de Batterie", + "name": "battery", + "refresh": 2000, + "ui": { + "access": "r", + "display": "icon", + "icon_range": [], + "icon_url": "/resources/images/home/pictos/batt_x.png", + "status_text_range": [], + "unit": "%" + }, + "value": 100, + "value_type": "int", + "visibility": "normal" + } + ], + "signal_links": [ + { + "adapter": 5, + "category": "alarm", + "id": 7, + "label": "Système d alarme", + "link_id": 12, + "name": "node_7", + "status": "active" + } + ], + "slot_links": [], + "status": "active", + "type": { + "abstract": false, + "endpoints": [ + { + "ep_type": "slot", + "id": 0, + "label": "Alarme principale", + "name": "alarm1", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "slot", + "id": 1, + "label": "Alarme secondaire", + "name": "alarm2", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "slot", + "id": 2, + "label": "Zone temporisée", + "name": "timed", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 3, + "label": "Alarme principale", + "name": "alarm1", + "param_type": "void", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 4, + "label": "Alarme secondaire", + "name": "alarm2", + "param_type": "void", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 5, + "label": "Zone temporisée", + "name": "timed", + "param_type": "void", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 6, + "label": "Détection", + "name": "trigger", + "param_type": "void", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 7, + "label": "Couvercle", + "name": "cover", + "param_type": "void", + "value_type": "bool", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 8, + "label": "Niveau de Batterie", + "name": "battery", + "param_type": "void", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 9, + "label": "Batterie faible", + "name": "battery_warning", + "param_type": "void", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 10, + "label": "Alarme", + "name": "alarm", + "param_type": "void", + "value_type": "void", + "visibility": "internal" + } + ], + "generic": false, + "icon": "/resources/images/home/pictos/detecteur_xxxx.png", + "inherit": "node::domus", + "label": "Détecteur infrarouge", + "name": "node::domus::sercomm::pir", + "params": {}, + "physical": true + } + }, + { + "adapter": 10, + "area": 38, + "category": "shutter", + "group": { + "label": "Salon" + }, + "id": 150, + "label": "Shutter 1", + "name": "node_150", + "type": { + "inherit": "node::trs" + } + }, + { + "adapter": 11, + "area": 38, + "category": "shutter", + "group": { + "label": "Salon" + }, + "id": 151, + "label": "Shutter 2", + "name": "node_151", + "type": { + "inherit": "node::ios" + } + }, + { + "adapter": 5, + "category": "alarm", + "group": { + "label": "" + }, + "id": 7, + "label": "Système d'alarme", + "name": "node_7", + "props": { + "Address": 3, + "Challenge": "447599f5cab8620122b913e55faf8e1d", + "FwVersion": 47396239, + "Gateway": 1, + "ItemId": "e515a55b04f32e6d" + }, + "show_endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 5, + "label": "Code PIN", + "name": "pin", + "ui": {}, + "value": "", + "value_type": "string", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 6, + "label": "Puissance des bips", + "name": "sound", + "ui": {}, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "slot", + "id": 7, + "label": "Puissance de la sirène", + "name": "volume", + "ui": {}, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "alarm", + "ep_type": "slot", + "id": 8, + "label": "Délai avant armement", + "name": "timeout1", + "ui": {}, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "alarm", + "ep_type": "slot", + "id": 9, + "label": "Délai avant sirène", + "name": "timeout2", + "ui": {}, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "alarm", + "ep_type": "slot", + "id": 10, + "label": "Durée de la sirène", + "name": "timeout3", + "ui": {}, + "value": 0, + "value_type": "int", + "visibility": "normal" + }, + { + "category": "", + "ep_type": "signal", + "id": 12, + "label": "Code PIN", + "name": "pin", + "refresh": 2000, + "ui": {}, + "value": "0000", + "value_type": "string" + }, + { + "category": "", + "ep_type": "signal", + "id": 14, + "label": "Puissance des bips", + "name": "sound", + "refresh": 2000, + "ui": {}, + "value": 1, + "value_type": "int" + }, + { + "category": "", + "ep_type": "signal", + "id": 15, + "label": "Puissance de la sirène", + "name": "volume", + "refresh": 2000, + "ui": {}, + "value": 100, + "value_type": "int" + }, + { + "category": "alarm", + "ep_type": "signal", + "id": 16, + "label": "Délai avant armement", + "name": "timeout1", + "refresh": 2000, + "ui": {}, + "value": 15, + "value_type": "int" + }, + { + "category": "alarm", + "ep_type": "signal", + "id": 17, + "label": "Délai avant sirène", + "name": "timeout2", + "refresh": 2000, + "ui": {}, + "value": 15, + "value_type": "int" + }, + { + "category": "alarm", + "ep_type": "signal", + "id": 18, + "label": "Durée de la sirène", + "name": "timeout3", + "refresh": 2000, + "ui": {}, + "value": 300, + "value_type": "int" + }, + { + "category": "", + "ep_type": "signal", + "id": 19, + "label": "Niveau de Batterie", + "name": "battery", + "refresh": 2000, + "ui": {}, + "value": 85, + "value_type": "int" + } + ], + "type": { + "abstract": false, + "endpoints": [ + { + "ep_type": "slot", + "id": 0, + "label": "Trigger", + "name": "trigger", + "value_type": "void", + "visibility": "internal" + }, + { + "ep_type": "slot", + "id": 1, + "label": "Alarme principale", + "name": "alarm1", + "value_type": "void", + "visibility": "normal" + }, + { + "ep_type": "slot", + "id": 2, + "label": "Alarme secondaire", + "name": "alarm2", + "value_type": "void", + "visibility": "internal" + }, + { + "ep_type": "slot", + "id": 3, + "label": "Passer le délai", + "name": "skip", + "value_type": "void", + "visibility": "internal" + }, + { + "ep_type": "slot", + "id": 4, + "label": "Désactiver l'alarme", + "name": "off", + "value_type": "void", + "visibility": "internal" + }, + { + "ep_type": "slot", + "id": 5, + "label": "Code PIN", + "name": "pin", + "value_type": "string", + "visibility": "normal" + }, + { + "ep_type": "slot", + "id": 6, + "label": "Puissance des bips", + "name": "sound", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "slot", + "id": 7, + "label": "Puissance de la sirène", + "name": "volume", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "slot", + "id": 8, + "label": "Délai avant armement", + "name": "timeout1", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "slot", + "id": 9, + "label": "Délai avant sirène", + "name": "timeout2", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "slot", + "id": 10, + "label": "Durée de la sirène", + "name": "timeout3", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 11, + "label": "État de l'alarme", + "name": "state", + "param_type": "void", + "value_type": "string", + "visibility": "internal" + }, + { + "ep_type": "signal", + "id": 12, + "label": "Code PIN", + "name": "pin", + "param_type": "void", + "value_type": "string", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 13, + "label": "Erreur", + "name": "error", + "param_type": "void", + "value_type": "string", + "visibility": "internal" + }, + { + "ep_type": "signal", + "id": 14, + "label": "Puissance des bips", + "name": "sound", + "param_type": "void", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 15, + "label": "Puissance de la sirène", + "name": "volume", + "param_type": "void", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 16, + "label": "Délai avant armement", + "name": "timeout1", + "param_type": "void", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 17, + "label": "Délai avant sirène", + "name": "timeout2", + "param_type": "void", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 18, + "label": "Durée de la sirène", + "name": "timeout3", + "param_type": "void", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 19, + "label": "Niveau de Batterie", + "name": "battery", + "param_type": "void", + "value_type": "int", + "visibility": "normal" + }, + { + "ep_type": "signal", + "id": 20, + "label": "Batterie faible", + "name": "battery_warning", + "param_type": "void", + "value_type": "int", + "visibility": "normal" + } + ], + "generic": false, + "icon": "/resources/images/home/pictos/alarm_system.png", + "inherit": "node::domus", + "label": "Système d'alarme", + "name": "node::domus::freebox::secmod", + "params": {}, + "physical": true + } + } +] diff --git a/tests/components/freebox/fixtures/home_pir_get_value.json b/tests/components/freebox/fixtures/home_pir_get_value.json new file mode 100644 index 00000000000..a76fdd66286 --- /dev/null +++ b/tests/components/freebox/fixtures/home_pir_get_value.json @@ -0,0 +1,14 @@ +{ + "category": "", + "ep_type": "signal", + "id": 6, + "label": "Détection", + "name": "trigger", + "ui": { + "access": "w", + "display": "toggle" + }, + "value": false, + "value_type": "bool", + "visibility": "normal" +} diff --git a/tests/components/freebox/fixtures/home_set_value.json b/tests/components/freebox/fixtures/home_set_value.json new file mode 100644 index 00000000000..5550c6db40a --- /dev/null +++ b/tests/components/freebox/fixtures/home_set_value.json @@ -0,0 +1,3 @@ +{ + "success": true +} diff --git a/tests/components/freebox/fixtures/lan_get_hosts_list.json b/tests/components/freebox/fixtures/lan_get_hosts_list.json new file mode 100644 index 00000000000..dccf6acee4a --- /dev/null +++ b/tests/components/freebox/fixtures/lan_get_hosts_list.json @@ -0,0 +1,274 @@ +[ + { + "l2ident": { + "id": "8C:97:EA:00:00:00", + "type": "mac_address" + }, + "active": true, + "persistent": false, + "names": [ + { + "name": "d633d0c8-958c-43cc-e807-d881b076924b", + "source": "mdns" + }, + { + "name": "Freebox Player POP", + "source": "mdns_srv" + } + ], + "vendor_name": "Freebox SAS", + "host_type": "smartphone", + "interface": "pub", + "id": "ether-8c:97:ea:00:00:00", + "last_time_reachable": 1614107652, + "primary_name_manual": false, + "l3connectivities": [ + { + "addr": "192.168.1.180", + "active": true, + "reachable": true, + "last_activity": 1614107614, + "af": "ipv4", + "last_time_reachable": 1614104242 + }, + { + "addr": "fe80::dcef:dbba:6604:31d1", + "active": true, + "reachable": true, + "last_activity": 1614107645, + "af": "ipv6", + "last_time_reachable": 1614107645 + }, + { + "addr": "2a01:e34:eda1:eb40:8102:4704:7ce0:2ace", + "active": false, + "reachable": false, + "last_activity": 1611574428, + "af": "ipv6", + "last_time_reachable": 1611574428 + }, + { + "addr": "2a01:e34:eda1:eb40:c8e5:c524:c96d:5f5e", + "active": false, + "reachable": false, + "last_activity": 1612475101, + "af": "ipv6", + "last_time_reachable": 1612475101 + }, + { + "addr": "2a01:e34:eda1:eb40:583a:49df:1df0:c2df", + "active": true, + "reachable": true, + "last_activity": 1614107652, + "af": "ipv6", + "last_time_reachable": 1614107652 + }, + { + "addr": "2a01:e34:eda1:eb40:147e:3569:86ab:6aaa", + "active": false, + "reachable": false, + "last_activity": 1612486752, + "af": "ipv6", + "last_time_reachable": 1612486752 + } + ], + "default_name": "Freebox Player POP", + "model": "fbx8am", + "reachable": true, + "last_activity": 1614107652, + "primary_name": "Freebox Player POP" + }, + { + "l2ident": { + "id": "DE:00:B0:00:00:00", + "type": "mac_address" + }, + "active": false, + "persistent": false, + "vendor_name": "", + "host_type": "workstation", + "interface": "pub", + "id": "ether-de:00:b0:00:00:00", + "last_time_reachable": 1607125599, + "primary_name_manual": false, + "default_name": "", + "l3connectivities": [ + { + "addr": "192.168.1.181", + "active": false, + "reachable": false, + "last_activity": 1607125599, + "af": "ipv4", + "last_time_reachable": 1607125599 + }, + { + "addr": "192.168.1.182", + "active": false, + "reachable": false, + "last_activity": 1605958758, + "af": "ipv4", + "last_time_reachable": 1605958758 + }, + { + "addr": "2a01:e34:eda1:eb40:dc00:b0ff:fedf:e30", + "active": false, + "reachable": false, + "last_activity": 1607125594, + "af": "ipv6", + "last_time_reachable": 1607125594 + } + ], + "reachable": false, + "last_activity": 1607125599, + "primary_name": "" + }, + { + "l2ident": { + "id": "DC:00:B0:00:00:00", + "type": "mac_address" + }, + "active": true, + "persistent": false, + "names": [ + { + "name": "Repeteur-Wifi-Freebox", + "source": "mdns" + }, + { + "name": "Repeteur Wifi Freebox", + "source": "mdns_srv" + } + ], + "vendor_name": "", + "host_type": "freebox_wifi", + "interface": "pub", + "id": "ether-dc:00:b0:00:00:00", + "last_time_reachable": 1614107678, + "primary_name_manual": false, + "l3connectivities": [ + { + "addr": "192.168.1.145", + "active": true, + "reachable": true, + "last_activity": 1614107678, + "af": "ipv4", + "last_time_reachable": 1614107678 + }, + { + "addr": "fe80::de00:b0ff:fe52:6ef6", + "active": true, + "reachable": true, + "last_activity": 1614107608, + "af": "ipv6", + "last_time_reachable": 1614107603 + }, + { + "addr": "2a01:e34:eda1:eb40:de00:b0ff:fe52:6ef6", + "active": true, + "reachable": true, + "last_activity": 1614107618, + "af": "ipv6", + "last_time_reachable": 1614107618 + } + ], + "default_name": "Repeteur Wifi Freebox", + "model": "fbxwmr", + "reachable": true, + "last_activity": 1614107678, + "primary_name": "Repeteur Wifi Freebox" + }, + { + "l2ident": { + "id": "5E:65:55:00:00:00", + "type": "mac_address" + }, + "active": false, + "persistent": false, + "names": [ + { + "name": "iPhoneofQuentin", + "source": "dhcp" + }, + { + "name": "iPhone-of-Quentin", + "source": "mdns" + } + ], + "vendor_name": "", + "host_type": "smartphone", + "interface": "pub", + "id": "ether-5e:65:55:00:00:00", + "last_time_reachable": 1612611982, + "primary_name_manual": false, + "default_name": "iPhonedeQuentin", + "l3connectivities": [ + { + "addr": "192.168.1.148", + "active": false, + "reachable": false, + "last_activity": 1612611973, + "af": "ipv4", + "last_time_reachable": 1612611973 + }, + { + "addr": "fe80::14ca:6c30:938b:e281", + "active": false, + "reachable": false, + "last_activity": 1609693223, + "af": "ipv6", + "last_time_reachable": 1609693223 + }, + { + "addr": "fe80::1c90:2b94:1ba2:bd8b", + "active": false, + "reachable": false, + "last_activity": 1610797303, + "af": "ipv6", + "last_time_reachable": 1610797303 + }, + { + "addr": "fe80::8c8:e58b:838e:6785", + "active": false, + "reachable": false, + "last_activity": 1612611951, + "af": "ipv6", + "last_time_reachable": 1612611946 + }, + { + "addr": "2a01:e34:eda1:eb40:f0e7:e198:3a69:58", + "active": false, + "reachable": false, + "last_activity": 1609693245, + "af": "ipv6", + "last_time_reachable": 1609693245 + }, + { + "addr": "2a01:e34:eda1:eb40:1dc4:c6f8:aa20:c83b", + "active": false, + "reachable": false, + "last_activity": 1610797176, + "af": "ipv6", + "last_time_reachable": 1610797176 + }, + { + "addr": "2a01:e34:eda1:eb40:6cf6:5811:1770:c662", + "active": false, + "reachable": false, + "last_activity": 1612611982, + "af": "ipv6", + "last_time_reachable": 1612611982 + }, + { + "addr": "2a01:e34:eda1:eb40:438:9b2c:4f8f:f48a", + "active": false, + "reachable": false, + "last_activity": 1612611946, + "af": "ipv6", + "last_time_reachable": 1612611946 + } + ], + "reachable": false, + "last_activity": 1612611982, + "primary_name": "iPhoneofQuentin" + } +] diff --git a/tests/components/freebox/fixtures/lan_get_hosts_list_bridge.json b/tests/components/freebox/fixtures/lan_get_hosts_list_bridge.json new file mode 100644 index 00000000000..4afda465712 --- /dev/null +++ b/tests/components/freebox/fixtures/lan_get_hosts_list_bridge.json @@ -0,0 +1,5 @@ +{ + "msg": "Erreur lors de la récupération de la liste des hôtes : Interface invalide", + "success": false, + "error_code": "nodev" +} diff --git a/tests/components/freebox/fixtures/storage_get_disks.json b/tests/components/freebox/fixtures/storage_get_disks.json new file mode 100644 index 00000000000..befeb592faf --- /dev/null +++ b/tests/components/freebox/fixtures/storage_get_disks.json @@ -0,0 +1,109 @@ +[ + { + "idle_duration": 0, + "read_error_requests": 0, + "read_requests": 1815106, + "spinning": true, + "table_type": "raid", + "firmware": "0001", + "type": "sata", + "idle": true, + "connector": 2, + "id": 1000, + "write_error_requests": 0, + "time_before_spindown": 600, + "state": "disabled", + "write_requests": 80386151, + "total_bytes": 2000000000000, + "model": "ST2000LM015-2E8174", + "active_duration": 0, + "temp": 30, + "serial": "ZDZLBFHC", + "partitions": [ + { + "fstype": "raid", + "total_bytes": 0, + "label": "Volume 2000Go", + "id": 1000, + "internal": false, + "fsck_result": "no_run_yet", + "state": "umounted", + "disk_id": 1000, + "free_bytes": 0, + "used_bytes": 0, + "path": "L1ZvbHVtZSAyMDAwR28=" + } + ] + }, + { + "idle_duration": 0, + "read_error_requests": 0, + "read_requests": 3622038, + "spinning": true, + "table_type": "raid", + "firmware": "0001", + "type": "sata", + "idle": true, + "connector": 0, + "id": 2000, + "write_error_requests": 0, + "time_before_spindown": 600, + "state": "disabled", + "write_requests": 80386151, + "total_bytes": 2000000000000, + "model": "ST2000LM015-2E8174", + "active_duration": 0, + "temp": 31, + "serial": "ZDZLEJXE", + "partitions": [ + { + "fstype": "raid", + "total_bytes": 0, + "label": "Volume 2000Go 1", + "id": 2000, + "internal": false, + "fsck_result": "no_run_yet", + "state": "umounted", + "disk_id": 2000, + "free_bytes": 0, + "used_bytes": 0, + "path": "L1ZvbHVtZSAyMDAwR28gMQ==" + } + ] + }, + { + "idle_duration": 0, + "read_error_requests": 0, + "read_requests": 0, + "spinning": false, + "table_type": "superfloppy", + "firmware": "", + "type": "raid", + "idle": false, + "connector": 0, + "id": 3000, + "write_error_requests": 0, + "state": "enabled", + "write_requests": 0, + "total_bytes": 2000000000000, + "model": "", + "active_duration": 0, + "temp": 0, + "serial": "", + "partitions": [ + { + "fstype": "ext4", + "total_bytes": 1960000000000, + "label": "Freebox", + "id": 3000, + "internal": false, + "fsck_result": "no_run_yet", + "state": "mounted", + "disk_id": 3000, + "free_bytes": 1730000000000, + "used_bytes": 236910000000, + "path": "L0ZyZWVib3g=" + } + ] + } +] diff --git a/tests/components/freebox/fixtures/storage_get_raids.json b/tests/components/freebox/fixtures/storage_get_raids.json new file mode 100644 index 00000000000..eb4e3c36681 --- /dev/null +++ b/tests/components/freebox/fixtures/storage_get_raids.json @@ -0,0 +1,64 @@ +[ + { + "degraded": false, + "raid_disks": 2, + "next_check": 0, + "sync_action": "idle", + "level": "raid1", + "uuid": "dc8679f8-13f9-11ee-9106-38d547790df8", + "sysfs_state": "clear", + "id": 0, + "sync_completed_pos": 0, + "members": [ + { + "total_bytes": 2000000000000, + "active_device": 1, + "id": 1000, + "corrected_read_errors": 0, + "array_id": 0, + "disk": { + "firmware": "0001", + "temp": 29, + "serial": "ZDZLBFHC", + "model": "ST2000LM015-2E8174" + }, + "role": "active", + "sct_erc_supported": false, + "sct_erc_enabled": false, + "dev_uuid": "fca8720e-13f9-11ee-9106-38d547790df8", + "device_location": "sata-internal-p2", + "set_name": "Freebox", + "set_uuid": "dc8679f8-13f9-11ee-9106-38d547790df8" + }, + { + "total_bytes": 2000000000000, + "active_device": 0, + "id": 2000, + "corrected_read_errors": 0, + "array_id": 0, + "disk": { + "firmware": "0001", + "temp": 30, + "serial": "ZDZLEJXE", + "model": "ST2000LM015-2E8174" + }, + "role": "active", + "sct_erc_supported": false, + "sct_erc_enabled": false, + "dev_uuid": "16bf00d6-13fa-11ee-9106-38d547790df8", + "device_location": "sata-internal-p0", + "set_name": "Freebox", + "set_uuid": "dc8679f8-13f9-11ee-9106-38d547790df8" + } + ], + "array_size": 2000000000000, + "state": "running", + "sync_speed": 0, + "name": "Freebox", + "check_interval": 0, + "disk_id": 3000, + "last_check": 1682884357, + "sync_completed_end": 0, + "sync_completed_percent": 0 + } +] diff --git a/tests/components/freebox/fixtures/system_get_config.json b/tests/components/freebox/fixtures/system_get_config.json new file mode 100644 index 00000000000..5dd72dcb4e3 --- /dev/null +++ b/tests/components/freebox/fixtures/system_get_config.json @@ -0,0 +1,57 @@ +{ + "mac": "68:A3:78:00:00:00", + "model_info": { + "has_ext_telephony": true, + "has_speakers_jack": true, + "wifi_type": "2d4_5g", + "pretty_name": "Freebox Server (r2)", + "customer_hdd_slots": 0, + "name": "fbxgw-r2/full", + "has_speakers": true, + "internal_hdd_size": 250, + "has_femtocell_exp": true, + "has_internal_hdd": true, + "has_dect": true + }, + "fans": [ + { + "id": "fan0_speed", + "name": "Ventilateur 1", + "value": 2130 + } + ], + "sensors": [ + { + "id": "temp_hdd", + "name": "Disque dur", + "value": 40 + }, + { + "id": "temp_hdd2", + "name": "Disque dur 2" + }, + { + "id": "temp_sw", + "name": "Température Switch", + "value": 50 + }, + { + "id": "temp_cpum", + "name": "Température CPU M", + "value": 60 + }, + { + "id": "temp_cpub", + "name": "Température CPU B", + "value": 56 + } + ], + "board_name": "fbxgw2r", + "disk_status": "active", + "uptime": "156 jours 19 heures 56 minutes 16 secondes", + "uptime_val": 13550176, + "user_main_storage": "Disque dur", + "box_authenticated": true, + "serial": "762601T190510709", + "firmware_version": "4.2.5" +} diff --git a/tests/components/freebox/fixtures/wifi_get_global_config.json b/tests/components/freebox/fixtures/wifi_get_global_config.json new file mode 100644 index 00000000000..189a039a69f --- /dev/null +++ b/tests/components/freebox/fixtures/wifi_get_global_config.json @@ -0,0 +1,4 @@ +{ + "enabled": true, + "mac_filter_state": "disabled" +} diff --git a/tests/components/freebox/test_alarm_control_panel.py b/tests/components/freebox/test_alarm_control_panel.py new file mode 100644 index 00000000000..44286f18b87 --- /dev/null +++ b/tests/components/freebox/test_alarm_control_panel.py @@ -0,0 +1,175 @@ +"""Tests for the Freebox alarms.""" +from copy import deepcopy +from unittest.mock import Mock + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.alarm_control_panel import ( + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, + AlarmControlPanelEntityFeature, +) +from homeassistant.components.freebox import SCAN_INTERVAL +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_DISARM, + SERVICE_ALARM_TRIGGER, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMING, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant + +from .common import setup_platform +from .const import DATA_HOME_ALARM_GET_VALUE, DATA_HOME_GET_NODES + +from tests.common import async_fire_time_changed + + +async def test_alarm_changed_from_external( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock +) -> None: + """Test Freebox Home alarm which state depends on external changes.""" + data_get_home_nodes = deepcopy(DATA_HOME_GET_NODES) + data_get_home_endpoint_value = deepcopy(DATA_HOME_ALARM_GET_VALUE) + + # Add remove arm_home feature + ALARM_NODE_ID = 7 + ALARM_HOME_ENDPOINT_ID = 2 + del data_get_home_nodes[ALARM_NODE_ID]["type"]["endpoints"][ALARM_HOME_ENDPOINT_ID] + router().home.get_home_nodes.return_value = data_get_home_nodes + + data_get_home_endpoint_value["value"] = "alarm1_arming" + router().home.get_home_endpoint_value.return_value = data_get_home_endpoint_value + + await setup_platform(hass, ALARM_CONTROL_PANEL_DOMAIN) + + # Attributes + assert hass.states.get("alarm_control_panel.systeme_d_alarme").attributes[ + "supported_features" + ] == ( + AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.TRIGGER + ) + + # Initial state + assert ( + hass.states.get("alarm_control_panel.systeme_d_alarme").state + == STATE_ALARM_ARMING + ) + + # Now simulate a changed status + data_get_home_endpoint_value["value"] = "alarm1_armed" + router().home.get_home_endpoint_value.return_value = data_get_home_endpoint_value + + # Simulate an update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("alarm_control_panel.systeme_d_alarme").state + == STATE_ALARM_ARMED_AWAY + ) + + +async def test_alarm_changed_from_hass(hass: HomeAssistant, router: Mock) -> None: + """Test Freebox Home alarm which state depends on HA.""" + data_get_home_endpoint_value = deepcopy(DATA_HOME_ALARM_GET_VALUE) + + data_get_home_endpoint_value["value"] = "alarm1_armed" + router().home.get_home_endpoint_value.return_value = data_get_home_endpoint_value + + await setup_platform(hass, ALARM_CONTROL_PANEL_DOMAIN) + + # Attributes + assert hass.states.get("alarm_control_panel.systeme_d_alarme").attributes[ + "supported_features" + ] == ( + AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.TRIGGER + ) + + # Initial state: arm_away + assert ( + hass.states.get("alarm_control_panel.systeme_d_alarme").state + == STATE_ALARM_ARMED_AWAY + ) + + # Now call for a change -> disarmed + data_get_home_endpoint_value["value"] = "idle" + router().home.get_home_endpoint_value.return_value = data_get_home_endpoint_value + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: ["alarm_control_panel.systeme_d_alarme"]}, + blocking=True, + ) + + assert ( + hass.states.get("alarm_control_panel.systeme_d_alarme").state + == STATE_ALARM_DISARMED + ) + + # Now call for a change -> arm_away + data_get_home_endpoint_value["value"] = "alarm1_arming" + router().home.get_home_endpoint_value.return_value = data_get_home_endpoint_value + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_ARM_AWAY, + {ATTR_ENTITY_ID: ["alarm_control_panel.systeme_d_alarme"]}, + blocking=True, + ) + + assert ( + hass.states.get("alarm_control_panel.systeme_d_alarme").state + == STATE_ALARM_ARMING + ) + + # Now call for a change -> arm_home + data_get_home_endpoint_value["value"] = "alarm2_armed" + # in reality: alarm2_arming then alarm2_armed + router().home.get_home_endpoint_value.return_value = data_get_home_endpoint_value + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_ARM_HOME, + {ATTR_ENTITY_ID: ["alarm_control_panel.systeme_d_alarme"]}, + blocking=True, + ) + + assert ( + hass.states.get("alarm_control_panel.systeme_d_alarme").state + == STATE_ALARM_ARMED_HOME + ) + + # Now call for a change -> trigger + data_get_home_endpoint_value["value"] = "alarm1_alert_timer" + router().home.get_home_endpoint_value.return_value = data_get_home_endpoint_value + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_TRIGGER, + {ATTR_ENTITY_ID: ["alarm_control_panel.systeme_d_alarme"]}, + blocking=True, + ) + + assert ( + hass.states.get("alarm_control_panel.systeme_d_alarme").state + == STATE_ALARM_TRIGGERED + ) + + +async def test_alarm_undefined_fetch_status(hass: HomeAssistant, router: Mock) -> None: + """Test Freebox Home alarm which state is undefined or null.""" + data_get_home_endpoint_value = deepcopy(DATA_HOME_ALARM_GET_VALUE) + data_get_home_endpoint_value["value"] = None + router().home.get_home_endpoint_value.return_value = data_get_home_endpoint_value + + await setup_platform(hass, ALARM_CONTROL_PANEL_DOMAIN) + + assert ( + hass.states.get("alarm_control_panel.systeme_d_alarme").state == STATE_UNKNOWN + ) diff --git a/tests/components/freebox/test_binary_sensor.py b/tests/components/freebox/test_binary_sensor.py index b37d6a3c72c..ee07af786be 100644 --- a/tests/components/freebox/test_binary_sensor.py +++ b/tests/components/freebox/test_binary_sensor.py @@ -1,4 +1,4 @@ -"""Tests for the Freebox sensors.""" +"""Tests for the Freebox binary sensors.""" from copy import deepcopy from unittest.mock import Mock @@ -13,7 +13,7 @@ from homeassistant.const import ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant from .common import setup_platform -from .const import DATA_HOME_GET_VALUES, DATA_STORAGE_GET_RAIDS +from .const import DATA_HOME_PIR_GET_VALUE, DATA_STORAGE_GET_RAIDS from tests.common import async_fire_time_changed @@ -73,7 +73,7 @@ async def test_home( assert hass.states.get("binary_sensor.ouverture_porte_couvercle").state == "off" # Now simulate a changed status - data_home_get_values_changed = deepcopy(DATA_HOME_GET_VALUES) + data_home_get_values_changed = deepcopy(DATA_HOME_PIR_GET_VALUE) data_home_get_values_changed["value"] = True router().home.get_home_endpoint_value.return_value = data_home_get_values_changed diff --git a/tests/components/freebox/test_button.py b/tests/components/freebox/test_button.py index 5f72b5968f1..209ab1e9fc2 100644 --- a/tests/components/freebox/test_button.py +++ b/tests/components/freebox/test_button.py @@ -1,4 +1,4 @@ -"""Tests for the Freebox config flow.""" +"""Tests for the Freebox buttons.""" from unittest.mock import ANY, AsyncMock, Mock, patch from pytest_unordered import unordered diff --git a/tests/components/freebox/test_device_tracker.py b/tests/components/freebox/test_device_tracker.py new file mode 100644 index 00000000000..6d4ca5fb7ee --- /dev/null +++ b/tests/components/freebox/test_device_tracker.py @@ -0,0 +1,49 @@ +"""Tests for the Freebox device trackers.""" +from unittest.mock import Mock + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.freebox import SCAN_INTERVAL +from homeassistant.core import HomeAssistant + +from .common import setup_platform + +from tests.common import async_fire_time_changed + + +async def test_router_mode( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + router: Mock, +) -> None: + """Test get_hosts_list invoqued multiple times if freebox into router mode.""" + await setup_platform(hass, DEVICE_TRACKER_DOMAIN) + + assert router().lan.get_hosts_list.call_count == 1 + + # Simulate an update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert router().lan.get_hosts_list.call_count == 2 + + +async def test_bridge_mode( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + router_bridge_mode: Mock, +) -> None: + """Test get_hosts_list invoqued once if freebox into bridge mode.""" + await setup_platform(hass, DEVICE_TRACKER_DOMAIN) + + assert router_bridge_mode().lan.get_hosts_list.call_count == 1 + + # Simulate an update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # If get_hosts_list failed, not called again + assert router_bridge_mode().lan.get_hosts_list.call_count == 1 diff --git a/tests/components/freebox/test_init.py b/tests/components/freebox/test_init.py index 85acfdccc4d..9064727fb7f 100644 --- a/tests/components/freebox/test_init.py +++ b/tests/components/freebox/test_init.py @@ -1,4 +1,4 @@ -"""Tests for the Freebox config flow.""" +"""Tests for the Freebox init.""" from unittest.mock import ANY, Mock, patch from pytest_unordered import unordered diff --git a/tests/components/freebox/test_router.py b/tests/components/freebox/test_router.py new file mode 100644 index 00000000000..572c168e665 --- /dev/null +++ b/tests/components/freebox/test_router.py @@ -0,0 +1,22 @@ +"""Tests for the Freebox utility methods.""" +import json + +from homeassistant.components.freebox.router import is_json + +from .const import DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE, DATA_WIFI_GET_GLOBAL_CONFIG + + +async def test_is_json() -> None: + """Test is_json method.""" + + # Valid JSON values + assert is_json("{}") + assert is_json('{ "simple":"json" }') + assert is_json(json.dumps(DATA_WIFI_GET_GLOBAL_CONFIG)) + assert is_json(json.dumps(DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE)) + + # Not valid JSON values + assert not is_json(None) + assert not is_json("") + assert not is_json("XXX") + assert not is_json("{XXX}") diff --git a/tests/components/freedompro/test_binary_sensor.py b/tests/components/freedompro/test_binary_sensor.py index 5efa5ca96f7..84e421a8653 100644 --- a/tests/components/freedompro/test_binary_sensor.py +++ b/tests/components/freedompro/test_binary_sensor.py @@ -45,6 +45,8 @@ from tests.common import async_fire_time_changed ) async def test_binary_sensor_get_state( hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, init_integration, entity_id: str, uid: str, @@ -53,10 +55,8 @@ async def test_binary_sensor_get_state( ) -> None: """Test states of the binary_sensor.""" init_integration - registry = er.async_get(hass) - registry_device = dr.async_get(hass) - device = registry_device.async_get_device(identifiers={("freedompro", uid)}) + device = device_registry.async_get_device(identifiers={("freedompro", uid)}) assert device is not None assert device.identifiers == {("freedompro", uid)} assert device.manufacturer == "Freedompro" @@ -67,7 +67,7 @@ async def test_binary_sensor_get_state( assert state assert state.attributes.get("friendly_name") == name - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -84,7 +84,7 @@ async def test_binary_sensor_get_state( assert state assert state.attributes.get("friendly_name") == name - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -110,7 +110,7 @@ async def test_binary_sensor_get_state( assert state assert state.attributes.get("friendly_name") == name - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid diff --git a/tests/components/freedompro/test_climate.py b/tests/components/freedompro/test_climate.py index 41a550b3c50..581c6d05448 100644 --- a/tests/components/freedompro/test_climate.py +++ b/tests/components/freedompro/test_climate.py @@ -28,11 +28,13 @@ from tests.common import async_fire_time_changed uid = "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*TWMYQKL3UVED4HSIIB9GXJWJZBQCXG-9VE-N2IUAIWI" -async def test_climate_get_state(hass: HomeAssistant, init_integration) -> None: +async def test_climate_get_state( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + init_integration, +) -> None: """Test states of the climate.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - device = device_registry.async_get_device(identifiers={("freedompro", uid)}) assert device is not None assert device.identifiers == {("freedompro", uid)} @@ -84,10 +86,11 @@ async def test_climate_get_state(hass: HomeAssistant, init_integration) -> None: assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20 -async def test_climate_set_off(hass: HomeAssistant, init_integration) -> None: +async def test_climate_set_off( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test set off climate.""" init_integration - entity_registry = er.async_get(hass) entity_id = "climate.thermostat" state = hass.states.get(entity_id) @@ -115,11 +118,10 @@ async def test_climate_set_off(hass: HomeAssistant, init_integration) -> None: async def test_climate_set_unsupported_hvac_mode( - hass: HomeAssistant, init_integration + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration ) -> None: """Test set unsupported hvac mode climate.""" init_integration - entity_registry = er.async_get(hass) entity_id = "climate.thermostat" state = hass.states.get(entity_id) @@ -139,10 +141,11 @@ async def test_climate_set_unsupported_hvac_mode( ) -async def test_climate_set_temperature(hass: HomeAssistant, init_integration) -> None: +async def test_climate_set_temperature( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test set temperature climate.""" init_integration - entity_registry = er.async_get(hass) entity_id = "climate.thermostat" state = hass.states.get(entity_id) @@ -185,11 +188,10 @@ async def test_climate_set_temperature(hass: HomeAssistant, init_integration) -> async def test_climate_set_temperature_unsupported_hvac_mode( - hass: HomeAssistant, init_integration + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration ) -> None: """Test set temperature climate unsupported hvac mode.""" init_integration - entity_registry = er.async_get(hass) entity_id = "climate.thermostat" state = hass.states.get(entity_id) diff --git a/tests/components/freedompro/test_cover.py b/tests/components/freedompro/test_cover.py index af54b1c2793..a4c837194fe 100644 --- a/tests/components/freedompro/test_cover.py +++ b/tests/components/freedompro/test_cover.py @@ -36,6 +36,8 @@ from tests.common import async_fire_time_changed ) async def test_cover_get_state( hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, init_integration, entity_id: str, uid: str, @@ -44,10 +46,8 @@ async def test_cover_get_state( ) -> None: """Test states of the cover.""" init_integration - registry = er.async_get(hass) - registry_device = dr.async_get(hass) - device = registry_device.async_get_device(identifiers={("freedompro", uid)}) + device = device_registry.async_get_device(identifiers={("freedompro", uid)}) assert device is not None assert device.identifiers == {("freedompro", uid)} assert device.manufacturer == "Freedompro" @@ -59,7 +59,7 @@ async def test_cover_get_state( assert state.state == STATE_CLOSED assert state.attributes.get("friendly_name") == name - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -76,7 +76,7 @@ async def test_cover_get_state( assert state assert state.attributes.get("friendly_name") == name - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -96,6 +96,7 @@ async def test_cover_get_state( ) async def test_cover_set_position( hass: HomeAssistant, + entity_registry: er.EntityRegistry, init_integration, entity_id: str, uid: str, @@ -104,14 +105,13 @@ async def test_cover_set_position( ) -> None: """Test set position of the cover.""" init_integration - registry = er.async_get(hass) state = hass.states.get(entity_id) assert state assert state.state == STATE_CLOSED assert state.attributes.get("friendly_name") == name - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -151,6 +151,7 @@ async def test_cover_set_position( ) async def test_cover_close( hass: HomeAssistant, + entity_registry: er.EntityRegistry, init_integration, entity_id: str, uid: str, @@ -159,7 +160,6 @@ async def test_cover_close( ) -> None: """Test close cover.""" init_integration - registry = er.async_get(hass) states_response = get_states_response_for_uid(uid) states_response[0]["state"]["position"] = 100 @@ -176,7 +176,7 @@ async def test_cover_close( assert state.state == STATE_OPEN assert state.attributes.get("friendly_name") == name - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -214,6 +214,7 @@ async def test_cover_close( ) async def test_cover_open( hass: HomeAssistant, + entity_registry: er.EntityRegistry, init_integration, entity_id: str, uid: str, @@ -222,14 +223,13 @@ async def test_cover_open( ) -> None: """Test open cover.""" init_integration - registry = er.async_get(hass) state = hass.states.get(entity_id) assert state assert state.state == STATE_CLOSED assert state.attributes.get("friendly_name") == name - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid diff --git a/tests/components/freedompro/test_fan.py b/tests/components/freedompro/test_fan.py index b5acf3e496a..80b1e5613eb 100644 --- a/tests/components/freedompro/test_fan.py +++ b/tests/components/freedompro/test_fan.py @@ -21,13 +21,16 @@ from tests.common import async_fire_time_changed uid = "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*ILYH1E3DWZOVMNEUIMDYMNLOW-LFRQFDPWWJOVHVDOS" -async def test_fan_get_state(hass: HomeAssistant, init_integration) -> None: +async def test_fan_get_state( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + init_integration, +) -> None: """Test states of the fan.""" init_integration - registry = er.async_get(hass) - registry_device = dr.async_get(hass) - device = registry_device.async_get_device(identifiers={("freedompro", uid)}) + device = device_registry.async_get_device(identifiers={("freedompro", uid)}) assert device is not None assert device.identifiers == {("freedompro", uid)} assert device.manufacturer == "Freedompro" @@ -41,7 +44,7 @@ async def test_fan_get_state(hass: HomeAssistant, init_integration) -> None: assert state.attributes[ATTR_PERCENTAGE] == 0 assert state.attributes.get("friendly_name") == "bedroom" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -59,7 +62,7 @@ async def test_fan_get_state(hass: HomeAssistant, init_integration) -> None: assert state assert state.attributes.get("friendly_name") == "bedroom" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -67,10 +70,11 @@ async def test_fan_get_state(hass: HomeAssistant, init_integration) -> None: assert state.attributes[ATTR_PERCENTAGE] == 50 -async def test_fan_set_off(hass: HomeAssistant, init_integration) -> None: +async def test_fan_set_off( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test turn off the fan.""" init_integration - registry = er.async_get(hass) entity_id = "fan.bedroom" @@ -91,7 +95,7 @@ async def test_fan_set_off(hass: HomeAssistant, init_integration) -> None: assert state.attributes[ATTR_PERCENTAGE] == 50 assert state.attributes.get("friendly_name") == "bedroom" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -120,10 +124,11 @@ async def test_fan_set_off(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_OFF -async def test_fan_set_on(hass: HomeAssistant, init_integration) -> None: +async def test_fan_set_on( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test turn on the fan.""" init_integration - registry = er.async_get(hass) entity_id = "fan.bedroom" state = hass.states.get(entity_id) @@ -132,7 +137,7 @@ async def test_fan_set_on(hass: HomeAssistant, init_integration) -> None: assert state.attributes[ATTR_PERCENTAGE] == 0 assert state.attributes.get("friendly_name") == "bedroom" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -160,10 +165,11 @@ async def test_fan_set_on(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_ON -async def test_fan_set_percent(hass: HomeAssistant, init_integration) -> None: +async def test_fan_set_percent( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test turn on the fan.""" init_integration - registry = er.async_get(hass) entity_id = "fan.bedroom" state = hass.states.get(entity_id) @@ -172,7 +178,7 @@ async def test_fan_set_percent(hass: HomeAssistant, init_integration) -> None: assert state.attributes[ATTR_PERCENTAGE] == 0 assert state.attributes.get("friendly_name") == "bedroom" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid diff --git a/tests/components/freedompro/test_light.py b/tests/components/freedompro/test_light.py index 1b06abd1e85..53cb59d5646 100644 --- a/tests/components/freedompro/test_light.py +++ b/tests/components/freedompro/test_light.py @@ -21,10 +21,11 @@ def mock_freedompro_put_state(): yield -async def test_light_get_state(hass: HomeAssistant, init_integration) -> None: +async def test_light_get_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test states of the light.""" init_integration - registry = er.async_get(hass) entity_id = "light.lightbulb" state = hass.states.get(entity_id) @@ -32,7 +33,7 @@ async def test_light_get_state(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_ON assert state.attributes.get("friendly_name") == "lightbulb" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert ( entry.unique_id @@ -40,10 +41,11 @@ async def test_light_get_state(hass: HomeAssistant, init_integration) -> None: ) -async def test_light_set_on(hass: HomeAssistant, init_integration) -> None: +async def test_light_set_on( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test set on of the light.""" init_integration - registry = er.async_get(hass) entity_id = "light.lightbulb" state = hass.states.get(entity_id) @@ -51,7 +53,7 @@ async def test_light_set_on(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_ON assert state.attributes.get("friendly_name") == "lightbulb" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert ( entry.unique_id @@ -70,10 +72,11 @@ async def test_light_set_on(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_ON -async def test_light_set_off(hass: HomeAssistant, init_integration) -> None: +async def test_light_set_off( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test set off of the light.""" init_integration - registry = er.async_get(hass) entity_id = "light.bedroomlight" state = hass.states.get(entity_id) @@ -81,7 +84,7 @@ async def test_light_set_off(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_OFF assert state.attributes.get("friendly_name") == "bedroomlight" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert ( entry.unique_id @@ -100,10 +103,11 @@ async def test_light_set_off(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_OFF -async def test_light_set_brightness(hass: HomeAssistant, init_integration) -> None: +async def test_light_set_brightness( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test set brightness of the light.""" init_integration - registry = er.async_get(hass) entity_id = "light.lightbulb" state = hass.states.get(entity_id) @@ -111,7 +115,7 @@ async def test_light_set_brightness(hass: HomeAssistant, init_integration) -> No assert state.state == STATE_ON assert state.attributes.get("friendly_name") == "lightbulb" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert ( entry.unique_id @@ -131,10 +135,11 @@ async def test_light_set_brightness(hass: HomeAssistant, init_integration) -> No assert int(state.attributes[ATTR_BRIGHTNESS]) == 0 -async def test_light_set_hue(hass: HomeAssistant, init_integration) -> None: +async def test_light_set_hue( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test set brightness of the light.""" init_integration - registry = er.async_get(hass) entity_id = "light.lightbulb" state = hass.states.get(entity_id) @@ -142,7 +147,7 @@ async def test_light_set_hue(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_ON assert state.attributes.get("friendly_name") == "lightbulb" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert ( entry.unique_id diff --git a/tests/components/freedompro/test_lock.py b/tests/components/freedompro/test_lock.py index c9f75e6b594..37145d6fe95 100644 --- a/tests/components/freedompro/test_lock.py +++ b/tests/components/freedompro/test_lock.py @@ -20,13 +20,16 @@ from tests.common import async_fire_time_changed uid = "2WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*2VAS3HTWINNZ5N6HVEIPDJ6NX85P2-AM-GSYWUCNPU0" -async def test_lock_get_state(hass: HomeAssistant, init_integration) -> None: +async def test_lock_get_state( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + init_integration, +) -> None: """Test states of the lock.""" init_integration - registry = er.async_get(hass) - registry_device = dr.async_get(hass) - device = registry_device.async_get_device(identifiers={("freedompro", uid)}) + device = device_registry.async_get_device(identifiers={("freedompro", uid)}) assert device is not None assert device.identifiers == {("freedompro", uid)} assert device.manufacturer == "Freedompro" @@ -39,7 +42,7 @@ async def test_lock_get_state(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_UNLOCKED assert state.attributes.get("friendly_name") == "lock" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -56,17 +59,18 @@ async def test_lock_get_state(hass: HomeAssistant, init_integration) -> None: assert state assert state.attributes.get("friendly_name") == "lock" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid assert state.state == STATE_LOCKED -async def test_lock_set_unlock(hass: HomeAssistant, init_integration) -> None: +async def test_lock_set_unlock( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test set on of the lock.""" init_integration - registry = er.async_get(hass) entity_id = "lock.lock" @@ -85,7 +89,7 @@ async def test_lock_set_unlock(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_LOCKED assert state.attributes.get("friendly_name") == "lock" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -111,10 +115,11 @@ async def test_lock_set_unlock(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_UNLOCKED -async def test_lock_set_lock(hass: HomeAssistant, init_integration) -> None: +async def test_lock_set_lock( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test set on of the lock.""" init_integration - registry = er.async_get(hass) entity_id = "lock.lock" state = hass.states.get(entity_id) @@ -122,7 +127,7 @@ async def test_lock_set_lock(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_UNLOCKED assert state.attributes.get("friendly_name") == "lock" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid diff --git a/tests/components/freedompro/test_sensor.py b/tests/components/freedompro/test_sensor.py index 89acfb3cc32..c06ce5b0794 100644 --- a/tests/components/freedompro/test_sensor.py +++ b/tests/components/freedompro/test_sensor.py @@ -34,17 +34,21 @@ from tests.common import async_fire_time_changed ], ) async def test_sensor_get_state( - hass: HomeAssistant, init_integration, entity_id: str, uid: str, name: str + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration, + entity_id: str, + uid: str, + name: str, ) -> None: """Test states of the sensor.""" init_integration - registry = er.async_get(hass) state = hass.states.get(entity_id) assert state assert state.attributes.get("friendly_name") == name - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -68,7 +72,7 @@ async def test_sensor_get_state( assert state assert state.attributes.get("friendly_name") == name - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid diff --git a/tests/components/freedompro/test_switch.py b/tests/components/freedompro/test_switch.py index 03647e4389d..7d72a87a7b5 100644 --- a/tests/components/freedompro/test_switch.py +++ b/tests/components/freedompro/test_switch.py @@ -16,10 +16,11 @@ from tests.common import async_fire_time_changed uid = "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*1JKU1MVWHQL-Z9SCUS85VFXMRGNDCDNDDUVVDKBU31W" -async def test_switch_get_state(hass: HomeAssistant, init_integration) -> None: +async def test_switch_get_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test states of the switch.""" init_integration - registry = er.async_get(hass) entity_id = "switch.irrigation_switch" state = hass.states.get(entity_id) @@ -27,7 +28,7 @@ async def test_switch_get_state(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_OFF assert state.attributes.get("friendly_name") == "Irrigation switch" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -44,17 +45,18 @@ async def test_switch_get_state(hass: HomeAssistant, init_integration) -> None: assert state assert state.attributes.get("friendly_name") == "Irrigation switch" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid assert state.state == STATE_ON -async def test_switch_set_off(hass: HomeAssistant, init_integration) -> None: +async def test_switch_set_off( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test set off of the switch.""" init_integration - registry = er.async_get(hass) entity_id = "switch.irrigation_switch" @@ -73,7 +75,7 @@ async def test_switch_set_off(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_ON assert state.attributes.get("friendly_name") == "Irrigation switch" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid @@ -101,10 +103,11 @@ async def test_switch_set_off(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_OFF -async def test_switch_set_on(hass: HomeAssistant, init_integration) -> None: +async def test_switch_set_on( + hass: HomeAssistant, entity_registry: er.EntityRegistry, init_integration +) -> None: """Test set on of the switch.""" init_integration - registry = er.async_get(hass) entity_id = "switch.irrigation_switch" state = hass.states.get(entity_id) @@ -112,7 +115,7 @@ async def test_switch_set_on(hass: HomeAssistant, init_integration) -> None: assert state.state == STATE_OFF assert state.attributes.get("friendly_name") == "Irrigation switch" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == uid diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index bb34af7c400..ded7cda0dea 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -48,9 +48,9 @@ async def test_user(hass: HomeAssistant, fc_class_mock, mock_get_source_ip) -> N ), patch( "homeassistant.components.fritz.async_setup_entry" ) as mock_setup_entry, patch( - "requests.get" + "requests.get", ) as mock_request_get, patch( - "requests.post" + "requests.post", ) as mock_request_post, patch( "homeassistant.components.fritz.config_flow.socket.gethostbyname", return_value=MOCK_IPS["fritz.box"], @@ -98,9 +98,9 @@ async def test_user_already_configured( "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", return_value=MOCK_FIRMWARE_INFO, ), patch( - "requests.get" + "requests.get", ) as mock_request_get, patch( - "requests.post" + "requests.post", ) as mock_request_post, patch( "homeassistant.components.fritz.config_flow.socket.gethostbyname", return_value=MOCK_IPS["fritz.box"], @@ -211,11 +211,11 @@ async def test_reauth_successful( "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", return_value=MOCK_FIRMWARE_INFO, ), patch( - "homeassistant.components.fritz.async_setup_entry" + "homeassistant.components.fritz.async_setup_entry", ) as mock_setup_entry, patch( - "requests.get" + "requests.get", ) as mock_request_get, patch( - "requests.post" + "requests.post", ) as mock_request_post: mock_request_get.return_value.status_code = 200 mock_request_get.return_value.content = MOCK_REQUEST @@ -399,9 +399,7 @@ async def test_ssdp(hass: HomeAssistant, fc_class_mock, mock_get_source_ip) -> N return_value=MOCK_FIRMWARE_INFO, ), patch( "homeassistant.components.fritz.async_setup_entry" - ) as mock_setup_entry, patch( - "requests.get" - ) as mock_request_get, patch( + ) as mock_setup_entry, patch("requests.get") as mock_request_get, patch( "requests.post" ) as mock_request_post: mock_request_get.return_value.status_code = 200 diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index 15ff04f3720..1faf37c84ee 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -45,6 +45,17 @@ async def setup_config_entry( return result +def set_devices( + fritz: Mock, devices: list[Mock] | None = None, templates: list[Mock] | None = None +) -> None: + """Set list of devices or templates.""" + if devices is not None: + fritz().get_devices.return_value = devices + + if templates is not None: + fritz().get_templates.return_value = templates + + class FritzEntityBaseMock(Mock): """base mock of a AVM Fritz!Box binary sensor device.""" diff --git a/tests/components/fritzbox/test_binary_sensor.py b/tests/components/fritzbox/test_binary_sensor.py index ac6b702147a..983516bb9c0 100644 --- a/tests/components/fritzbox/test_binary_sensor.py +++ b/tests/components/fritzbox/test_binary_sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from . import FritzDeviceBinarySensorMock, setup_config_entry +from . import FritzDeviceBinarySensorMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed @@ -126,3 +126,26 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 1 + + +async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: + """Test adding new discovered devices during runtime.""" + device = FritzDeviceBinarySensorMock() + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + state = hass.states.get(f"{ENTITY_ID}_alarm") + assert state + + new_device = FritzDeviceBinarySensorMock() + new_device.ain = "7890 1234" + new_device.name = "new_device" + set_devices(fritz, devices=[device, new_device]) + + next_update = dt_util.utcnow() + timedelta(seconds=200) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.new_device_alarm") + assert state diff --git a/tests/components/fritzbox/test_button.py b/tests/components/fritzbox/test_button.py index 9c53c895f5d..8c0bbec573e 100644 --- a/tests/components/fritzbox/test_button.py +++ b/tests/components/fritzbox/test_button.py @@ -1,4 +1,5 @@ """Tests for AVM Fritz!Box templates.""" +from datetime import timedelta from unittest.mock import Mock from homeassistant.components.button import DOMAIN, SERVICE_PRESS @@ -10,10 +11,13 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util -from . import FritzEntityBaseMock, setup_config_entry +from . import FritzEntityBaseMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG +from tests.common import async_fire_time_changed + ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" @@ -41,3 +45,26 @@ async def test_apply_template(hass: HomeAssistant, fritz: Mock) -> None: DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert fritz().apply_template.call_count == 1 + + +async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: + """Test adding new discovered devices during runtime.""" + template = FritzEntityBaseMock() + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], fritz=fritz, template=template + ) + + state = hass.states.get(ENTITY_ID) + assert state + + new_template = FritzEntityBaseMock() + new_template.ain = "7890 1234" + new_template.name = "new_template" + set_devices(fritz, templates=[template, new_template]) + + next_update = dt_util.utcnow() + timedelta(seconds=200) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.new_template") + assert state diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index d49b5710a12..a14c53d6529 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -41,7 +41,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from . import FritzDeviceClimateMock, setup_config_entry +from . import FritzDeviceClimateMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed @@ -402,3 +402,26 @@ async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: assert fritz().update_devices.call_count == 3 assert state assert state.attributes[ATTR_PRESET_MODE] == PRESET_ECO + + +async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: + """Test adding new discovered devices during runtime.""" + device = FritzDeviceClimateMock() + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + state = hass.states.get(ENTITY_ID) + assert state + + new_device = FritzDeviceClimateMock() + new_device.ain = "7890 1234" + new_device.name = "new_climate" + set_devices(fritz, devices=[device, new_device]) + + next_update = dt_util.utcnow() + timedelta(seconds=200) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.new_climate") + assert state diff --git a/tests/components/fritzbox/test_cover.py b/tests/components/fritzbox/test_cover.py index af725ce93da..e3a6d786abf 100644 --- a/tests/components/fritzbox/test_cover.py +++ b/tests/components/fritzbox/test_cover.py @@ -1,4 +1,5 @@ """Tests for AVM Fritz!Box switch component.""" +from datetime import timedelta from unittest.mock import Mock, call from homeassistant.components.cover import ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN @@ -12,10 +13,13 @@ from homeassistant.const import ( SERVICE_STOP_COVER, ) from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util -from . import FritzDeviceCoverMock, setup_config_entry +from . import FritzDeviceCoverMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG +from tests.common import async_fire_time_changed + ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" @@ -84,3 +88,26 @@ async def test_stop_cover(hass: HomeAssistant, fritz: Mock) -> None: DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert device.set_blind_stop.call_count == 1 + + +async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: + """Test adding new discovered devices during runtime.""" + device = FritzDeviceCoverMock() + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + state = hass.states.get(ENTITY_ID) + assert state + + new_device = FritzDeviceCoverMock() + new_device.ain = "7890 1234" + new_device.name = "new_climate" + set_devices(fritz, devices=[device, new_device]) + + next_update = dt_util.utcnow() + timedelta(seconds=200) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.new_climate") + assert state diff --git a/tests/components/fritzbox/test_init.py b/tests/components/fritzbox/test_init.py index b07b8225c3e..b8273204325 100644 --- a/tests/components/fritzbox/test_init.py +++ b/tests/components/fritzbox/test_init.py @@ -72,6 +72,7 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: ) async def test_update_unique_id( hass: HomeAssistant, + entity_registry: er.EntityRegistry, fritz: Mock, entitydata: dict, old_unique_id: str, @@ -85,7 +86,6 @@ async def test_update_unique_id( ) entry.add_to_hass(hass) - entity_registry = er.async_get(hass) entity: er.RegistryEntry = entity_registry.async_get_or_create( **entitydata, config_entry=entry, @@ -131,6 +131,7 @@ async def test_update_unique_id( ) async def test_update_unique_id_no_change( hass: HomeAssistant, + entity_registry: er.EntityRegistry, fritz: Mock, entitydata: dict, unique_id: str, @@ -143,7 +144,6 @@ async def test_update_unique_id_no_change( ) entry.add_to_hass(hass) - entity_registry = er.async_get(hass) entity = entity_registry.async_get_or_create( **entitydata, config_entry=entry, @@ -296,7 +296,7 @@ async def test_remove_device( ) response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" await hass.async_block_till_done() # try to delete orphan_device diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index 5511b93ac3f..858b564cd18 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -29,7 +29,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from . import FritzDeviceLightMock, setup_config_entry +from . import FritzDeviceLightMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed @@ -262,3 +262,38 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: assert fritz().update_devices.call_count == 4 assert fritz().login.call_count == 4 + + +async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: + """Test adding new discovered devices during runtime.""" + device = FritzDeviceLightMock() + device.get_color_temps.return_value = [2700, 6500] + device.get_colors.return_value = { + "Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")] + } + device.color_mode = COLOR_TEMP_MODE + device.color_temp = 2700 + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + state = hass.states.get(ENTITY_ID) + assert state + + new_device = FritzDeviceLightMock() + new_device.ain = "7890 1234" + new_device.name = "new_light" + new_device.get_color_temps.return_value = [2700, 6500] + new_device.get_colors.return_value = { + "Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")] + } + new_device.color_mode = COLOR_TEMP_MODE + new_device.color_temp = 2700 + set_devices(fritz, devices=[device, new_device]) + + next_update = dt_util.utcnow() + timedelta(seconds=200) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.new_light") + assert state diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index b4c0209e9af..9fe25d02ed0 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util -from . import FritzDeviceSensorMock, setup_config_entry +from . import FritzDeviceSensorMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed @@ -26,7 +26,9 @@ from tests.common import async_fire_time_changed ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, entity_registry: er.EntityRegistry, fritz: Mock +) -> None: """Test setup of platform.""" device = FritzDeviceSensorMock() assert await setup_config_entry( @@ -61,7 +63,6 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: ], ) - entity_registry = er.async_get(hass) for sensor in sensors: state = hass.states.get(sensor[0]) assert state @@ -107,3 +108,26 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: assert fritz().update_devices.call_count == 4 assert fritz().login.call_count == 4 + + +async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: + """Test adding new discovered devices during runtime.""" + device = FritzDeviceSensorMock() + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + state = hass.states.get(f"{ENTITY_ID}_temperature") + assert state + + new_device = FritzDeviceSensorMock() + new_device.ain = "7890 1234" + new_device.name = "new_device" + set_devices(fritz, devices=[device, new_device]) + + next_update = dt_util.utcnow() + timedelta(seconds=200) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.new_device_temperature") + assert state diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index 53cdf5147fc..aefe21e3ffc 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -31,7 +31,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util -from . import FritzDeviceSwitchMock, setup_config_entry +from . import FritzDeviceSwitchMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed @@ -39,7 +39,9 @@ from tests.common import async_fire_time_changed ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, entity_registry: er.EntityRegistry, fritz: Mock +) -> None: """Test setup of platform.""" device = FritzDeviceSwitchMock() assert await setup_config_entry( @@ -98,7 +100,6 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: ], ) - entity_registry = er.async_get(hass) for sensor in sensors: state = hass.states.get(sensor[0]) assert state @@ -186,3 +187,26 @@ async def test_assume_device_unavailable(hass: HomeAssistant, fritz: Mock) -> No state = hass.states.get(ENTITY_ID) assert state assert state.state == STATE_UNAVAILABLE + + +async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: + """Test adding new discovered devices during runtime.""" + device = FritzDeviceSwitchMock() + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + state = hass.states.get(ENTITY_ID) + assert state + + new_device = FritzDeviceSwitchMock() + new_device.ain = "7890 1234" + new_device.name = "new_switch" + set_devices(fritz, devices=[device, new_device]) + + next_update = dt_util.utcnow() + timedelta(seconds=200) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.new_switch") + assert state diff --git a/tests/components/fronius/test_init.py b/tests/components/fronius/test_init.py index d46c60c3cb3..cc56fea24b2 100644 --- a/tests/components/fronius/test_init.py +++ b/tests/components/fronius/test_init.py @@ -60,7 +60,9 @@ async def test_inverter_error( async def test_inverter_night_rescan( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + aioclient_mock: AiohttpClientMocker, ) -> 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) @@ -79,7 +81,6 @@ async def test_inverter_night_rescan( await hass.async_block_till_done() # We expect our inverter to be present now - device_registry = dr.async_get(hass) inverter_1 = device_registry.async_get_device(identifiers={(DOMAIN, "203200")}) assert inverter_1.manufacturer == "Fronius" @@ -93,13 +94,14 @@ async def test_inverter_night_rescan( async def test_inverter_rescan_interruption( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test interruption of re-scan during runtime to process further.""" mock_responses(aioclient_mock, fixture_set="igplus_v2", night=True) config_entry = await setup_fronius_integration(hass, is_logger=True) assert config_entry.state is ConfigEntryState.LOADED - device_registry = dr.async_get(hass) # Expect 1 devices during the night, logger assert ( len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index f94b0f3a55c..684e9a3ae5f 100644 --- a/tests/components/fronius/test_sensor.py +++ b/tests/components/fronius/test_sensor.py @@ -1,6 +1,6 @@ """Tests for the Fronius sensor platform.""" - from freezegun.api import FrozenDateTimeFactory +import pytest from homeassistant.components.fronius.const import DOMAIN from homeassistant.components.fronius.coordinator import ( @@ -33,33 +33,34 @@ async def test_symo_inverter( mock_responses(aioclient_mock, night=True) config_entry = await setup_fronius_integration(hass) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 20 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 21 await enable_all_entities( hass, freezer, config_entry.entry_id, FroniusInverterUpdateCoordinator.default_interval, ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 52 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 assert_state("sensor.symo_20_dc_current", 0) assert_state("sensor.symo_20_energy_day", 10828) assert_state("sensor.symo_20_total_energy", 44186900) assert_state("sensor.symo_20_energy_year", 25507686) assert_state("sensor.symo_20_dc_voltage", 16) + assert_state("sensor.symo_20_status_message", "startup") # Second test at daytime when inverter is producing mock_responses(aioclient_mock, night=False) 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)) == 56 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58 await enable_all_entities( hass, freezer, config_entry.entry_id, FroniusInverterUpdateCoordinator.default_interval, ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 60 # 4 additional AC entities assert_state("sensor.symo_20_dc_current", 2.19) assert_state("sensor.symo_20_energy_day", 1113) @@ -70,6 +71,7 @@ async def test_symo_inverter( assert_state("sensor.symo_20_frequency", 49.94) assert_state("sensor.symo_20_ac_power", 1190) assert_state("sensor.symo_20_ac_voltage", 227.90) + assert_state("sensor.symo_20_status_message", "running") # Third test at nighttime - additional AC entities default to 0 mock_responses(aioclient_mock, night=True) @@ -94,7 +96,7 @@ async def test_symo_logger( mock_responses(aioclient_mock) await setup_fronius_integration(hass) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 24 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 25 # states are rounded to 4 decimals assert_state("sensor.solarnet_grid_export_tariff", 0.078) assert_state("sensor.solarnet_co2_factor", 0.53) @@ -116,14 +118,14 @@ async def test_symo_meter( mock_responses(aioclient_mock) config_entry = await setup_fronius_integration(hass) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 24 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 25 await enable_all_entities( hass, freezer, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval, ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 60 # states are rounded to 4 decimals assert_state("sensor.smart_meter_63a_current_phase_1", 7.755) assert_state("sensor.smart_meter_63a_current_phase_2", 6.68) @@ -157,6 +159,50 @@ async def test_symo_meter( assert_state("sensor.smart_meter_63a_voltage_phase_1_2", 395.9) assert_state("sensor.smart_meter_63a_voltage_phase_2_3", 398) assert_state("sensor.smart_meter_63a_voltage_phase_3_1", 398) + assert_state("sensor.smart_meter_63a_meter_location", 0) + assert_state("sensor.smart_meter_63a_meter_location_description", "feed_in") + + +@pytest.mark.parametrize( + ("location_code", "expected_code", "expected_description"), + [ + (-1, -1, "unknown"), + (3, 3, "external_generator"), + (4, 4, "external_battery"), + (7, 7, "unknown"), + (256, 256, "subload"), + (511, 511, "subload"), + (512, 512, "unknown"), + ], +) +async def test_symo_meter_forged( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + location_code: int | None, + expected_code: int | str, + expected_description: str, +) -> None: + """Tests for meter location codes we have no fixture for.""" + + def assert_state(entity_id, expected_state): + state = hass.states.get(entity_id) + assert state + assert state.state == str(expected_state) + + mock_responses( + aioclient_mock, + fixture_set="symo", + override_data={ + "symo/GetMeterRealtimeData.json": [ + (["Body", "Data", "0", "Meter_Location_Current"], location_code), + ], + }, + ) + await setup_fronius_integration(hass) + assert_state("sensor.smart_meter_63a_meter_location", expected_code) + assert_state( + "sensor.smart_meter_63a_meter_location_description", expected_description + ) async def test_symo_power_flow( @@ -175,14 +221,14 @@ async def test_symo_power_flow( mock_responses(aioclient_mock, night=True) config_entry = await setup_fronius_integration(hass) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 20 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 21 await enable_all_entities( hass, freezer, config_entry.entry_id, FroniusInverterUpdateCoordinator.default_interval, ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 52 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 # states are rounded to 4 decimals assert_state("sensor.solarnet_energy_day", 10828) assert_state("sensor.solarnet_total_energy", 44186900) @@ -197,7 +243,7 @@ async def test_symo_power_flow( async_fire_time_changed(hass) await hass.async_block_till_done() # 54 because power_flow `rel_SelfConsumption` and `P_PV` is not `null` anymore - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 56 assert_state("sensor.solarnet_energy_day", 1101.7001) assert_state("sensor.solarnet_total_energy", 44188000) assert_state("sensor.solarnet_energy_year", 25508788) @@ -212,7 +258,7 @@ async def test_symo_power_flow( freezer.tick(FroniusPowerFlowUpdateCoordinator.default_interval) async_fire_time_changed(hass) await hass.async_block_till_done() - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 56 assert_state("sensor.solarnet_energy_day", 10828) assert_state("sensor.solarnet_total_energy", 44186900) assert_state("sensor.solarnet_energy_year", 25507686) @@ -238,18 +284,19 @@ 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)) == 22 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 23 await enable_all_entities( hass, freezer, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval, ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 52 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 # 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) @@ -264,7 +311,8 @@ async def test_gen24( 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.0) + 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) @@ -336,14 +384,14 @@ async def test_gen24_storage( hass, is_logger=False, unique_id="12345678" ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 34 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 35 await enable_all_entities( hass, freezer, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval, ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 64 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 66 # inverter 1 assert_state("sensor.gen24_storage_dc_current", 0.3952) assert_state("sensor.gen24_storage_dc_voltage_2", 318.8103) @@ -352,6 +400,7 @@ async def test_gen24_storage( 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) @@ -363,7 +412,8 @@ async def test_gen24_storage( 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.0) + 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) @@ -396,7 +446,7 @@ async def test_gen24_storage( 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") + 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) @@ -463,14 +513,14 @@ 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)) == 29 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 30 await enable_all_entities( hass, freezer, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval, ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 40 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 43 # logger assert_state("sensor.solarnet_grid_export_tariff", 1) assert_state("sensor.solarnet_co2_factor", 0.53) @@ -483,6 +533,7 @@ async def test_primo_s0( 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) @@ -497,6 +548,7 @@ async def test_primo_s0( 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) @@ -505,6 +557,9 @@ async def test_primo_s0( 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) diff --git a/tests/components/fully_kiosk/conftest.py b/tests/components/fully_kiosk/conftest.py index bed08b532fd..e409a0a3787 100644 --- a/tests/components/fully_kiosk/conftest.py +++ b/tests/components/fully_kiosk/conftest.py @@ -8,7 +8,13 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.fully_kiosk.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_SSL, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture @@ -24,6 +30,8 @@ def mock_config_entry() -> MockConfigEntry: CONF_HOST: "127.0.0.1", CONF_PASSWORD: "mocked-password", CONF_MAC: "aa:bb:cc:dd:ee:ff", + CONF_SSL: False, + CONF_VERIFY_SSL: False, }, unique_id="12345", ) diff --git a/tests/components/fully_kiosk/test_binary_sensor.py b/tests/components/fully_kiosk/test_binary_sensor.py index db37139b0ba..cc003199f26 100644 --- a/tests/components/fully_kiosk/test_binary_sensor.py +++ b/tests/components/fully_kiosk/test_binary_sensor.py @@ -22,14 +22,13 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_binary_sensors( hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, mock_fully_kiosk: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test standard Fully Kiosk binary sensors.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - state = hass.states.get("binary_sensor.amazon_fire_plugged_in") assert state assert state.state == STATE_ON diff --git a/tests/components/fully_kiosk/test_button.py b/tests/components/fully_kiosk/test_button.py index fee39be302e..f04935aed0e 100644 --- a/tests/components/fully_kiosk/test_button.py +++ b/tests/components/fully_kiosk/test_button.py @@ -12,13 +12,12 @@ from tests.common import MockConfigEntry async def test_buttons( hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, mock_fully_kiosk: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test standard Fully Kiosk buttons.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - entry = entity_registry.async_get("button.amazon_fire_restart_browser") assert entry assert entry.unique_id == "abcdef-123456-restartApp" diff --git a/tests/components/fully_kiosk/test_config_flow.py b/tests/components/fully_kiosk/test_config_flow.py index 566f3b6d292..018a62b5dc7 100644 --- a/tests/components/fully_kiosk/test_config_flow.py +++ b/tests/components/fully_kiosk/test_config_flow.py @@ -10,7 +10,13 @@ import pytest from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.fully_kiosk.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_MQTT, SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_SSL, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.mqtt import MqttServiceInfo @@ -35,6 +41,8 @@ async def test_user_flow( { CONF_HOST: "1.1.1.1", CONF_PASSWORD: "test-password", + CONF_SSL: False, + CONF_VERIFY_SSL: False, }, ) @@ -44,6 +52,8 @@ async def test_user_flow( CONF_HOST: "1.1.1.1", CONF_PASSWORD: "test-password", CONF_MAC: "aa:bb:cc:dd:ee:ff", + CONF_SSL: False, + CONF_VERIFY_SSL: False, } assert "result" in result2 assert result2["result"].unique_id == "12345" @@ -76,7 +86,13 @@ async def test_errors( mock_fully_kiosk_config_flow.getDeviceInfo.side_effect = side_effect result2 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_HOST: "1.1.1.1", CONF_PASSWORD: "test-password"} + flow_id, + user_input={ + CONF_HOST: "1.1.1.1", + CONF_PASSWORD: "test-password", + CONF_SSL: False, + CONF_VERIFY_SSL: False, + }, ) assert result2.get("type") == FlowResultType.FORM @@ -88,7 +104,13 @@ async def test_errors( mock_fully_kiosk_config_flow.getDeviceInfo.side_effect = None result3 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_HOST: "1.1.1.1", CONF_PASSWORD: "test-password"} + flow_id, + user_input={ + CONF_HOST: "1.1.1.1", + CONF_PASSWORD: "test-password", + CONF_SSL: True, + CONF_VERIFY_SSL: False, + }, ) assert result3.get("type") == FlowResultType.CREATE_ENTRY @@ -97,6 +119,8 @@ async def test_errors( CONF_HOST: "1.1.1.1", CONF_PASSWORD: "test-password", CONF_MAC: "aa:bb:cc:dd:ee:ff", + CONF_SSL: True, + CONF_VERIFY_SSL: False, } assert "result" in result3 assert result3["result"].unique_id == "12345" @@ -124,6 +148,8 @@ async def test_duplicate_updates_existing_entry( { CONF_HOST: "1.1.1.1", CONF_PASSWORD: "test-password", + CONF_SSL: True, + CONF_VERIFY_SSL: True, }, ) @@ -133,6 +159,8 @@ async def test_duplicate_updates_existing_entry( CONF_HOST: "1.1.1.1", CONF_PASSWORD: "test-password", CONF_MAC: "aa:bb:cc:dd:ee:ff", + CONF_SSL: True, + CONF_VERIFY_SSL: True, } assert len(mock_fully_kiosk_config_flow.getDeviceInfo.mock_calls) == 1 @@ -161,6 +189,8 @@ async def test_dhcp_discovery_updates_entry( CONF_HOST: "127.0.0.2", CONF_PASSWORD: "mocked-password", CONF_MAC: "aa:bb:cc:dd:ee:ff", + CONF_SSL: False, + CONF_VERIFY_SSL: False, } @@ -212,6 +242,8 @@ async def test_mqtt_discovery_flow( result["flow_id"], { CONF_PASSWORD: "test-password", + CONF_SSL: False, + CONF_VERIFY_SSL: False, }, ) @@ -222,6 +254,8 @@ async def test_mqtt_discovery_flow( CONF_HOST: "192.168.1.234", CONF_PASSWORD: "test-password", CONF_MAC: "aa:bb:cc:dd:ee:ff", + CONF_SSL: False, + CONF_VERIFY_SSL: False, } assert "result" in confirmResult assert confirmResult["result"].unique_id == "12345" diff --git a/tests/components/fully_kiosk/test_diagnostics.py b/tests/components/fully_kiosk/test_diagnostics.py index b1b30bda669..e48867739e8 100644 --- a/tests/components/fully_kiosk/test_diagnostics.py +++ b/tests/components/fully_kiosk/test_diagnostics.py @@ -17,13 +17,12 @@ from tests.typing import ClientSessionGenerator async def test_diagnostics( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, hass_client: ClientSessionGenerator, mock_fully_kiosk: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test Fully Kiosk diagnostics.""" - - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, "abcdef-123456")}) diagnostics = await get_diagnostics_for_device( diff --git a/tests/components/fully_kiosk/test_init.py b/tests/components/fully_kiosk/test_init.py index c53e4168733..2e77cdb2f1d 100644 --- a/tests/components/fully_kiosk/test_init.py +++ b/tests/components/fully_kiosk/test_init.py @@ -9,7 +9,13 @@ import pytest from homeassistant.components.fully_kiosk.const import DOMAIN from homeassistant.components.fully_kiosk.entity import valid_global_mac_address from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_SSL, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -81,11 +87,10 @@ async def _load_config( async def test_multiple_kiosk_with_empty_mac( hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, ) -> None: """Test that multiple kiosk devices with empty MAC don't get merged.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - config_entry1 = MockConfigEntry( title="Test device 1", domain=DOMAIN, @@ -93,6 +98,8 @@ async def test_multiple_kiosk_with_empty_mac( CONF_HOST: "127.0.0.1", CONF_PASSWORD: "mocked-password", CONF_MAC: "", + CONF_SSL: False, + CONF_VERIFY_SSL: False, }, unique_id="111111", ) @@ -106,6 +113,8 @@ async def test_multiple_kiosk_with_empty_mac( CONF_HOST: "127.0.0.2", CONF_PASSWORD: "mocked-password", CONF_MAC: "", + CONF_SSL: True, + CONF_VERIFY_SSL: False, }, unique_id="22222", ) diff --git a/tests/components/fully_kiosk/test_media_player.py b/tests/components/fully_kiosk/test_media_player.py index 403b9e26511..4cae64e641e 100644 --- a/tests/components/fully_kiosk/test_media_player.py +++ b/tests/components/fully_kiosk/test_media_player.py @@ -15,13 +15,12 @@ from tests.typing import WebSocketGenerator async def test_media_player( hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, mock_fully_kiosk: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test standard Fully Kiosk media player.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - state = hass.states.get("media_player.amazon_fire") assert state diff --git a/tests/components/fully_kiosk/test_number.py b/tests/components/fully_kiosk/test_number.py index 4843e72465c..286ca7fc0cb 100644 --- a/tests/components/fully_kiosk/test_number.py +++ b/tests/components/fully_kiosk/test_number.py @@ -13,13 +13,12 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_numbers( hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, mock_fully_kiosk: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test standard Fully Kiosk numbers.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - state = hass.states.get("number.amazon_fire_screensaver_timer") assert state assert state.state == "900" diff --git a/tests/components/fully_kiosk/test_sensor.py b/tests/components/fully_kiosk/test_sensor.py index 05fd002a205..40912f0f568 100644 --- a/tests/components/fully_kiosk/test_sensor.py +++ b/tests/components/fully_kiosk/test_sensor.py @@ -26,14 +26,13 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_sensors_sensors( hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, mock_fully_kiosk: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test standard Fully Kiosk sensors.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - state = hass.states.get("sensor.amazon_fire_battery") assert state assert state.state == "100" diff --git a/tests/components/fully_kiosk/test_services.py b/tests/components/fully_kiosk/test_services.py index 11d5a74f3d7..af6199f34d9 100644 --- a/tests/components/fully_kiosk/test_services.py +++ b/tests/components/fully_kiosk/test_services.py @@ -23,11 +23,11 @@ from tests.common import MockConfigEntry async def test_services( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_fully_kiosk: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test the Fully Kiosk Browser services.""" - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device( identifiers={(DOMAIN, "abcdef-123456")} ) @@ -103,13 +103,13 @@ async def test_services( async def test_service_unloaded_entry( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_fully_kiosk: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test service not called when config entry unloaded.""" await init_integration.async_unload(hass) - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device( identifiers={(DOMAIN, "abcdef-123456")} ) @@ -156,12 +156,11 @@ async def test_service_bad_device_id( async def test_service_called_with_non_fkb_target_devices( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_fully_kiosk: MagicMock, init_integration: MockConfigEntry, ) -> None: """Services raise exception when no valid devices provided.""" - device_registry = dr.async_get(hass) - other_domain = "NotFullyKiosk" other_config_id = "555" await hass.config_entries.async_add( diff --git a/tests/components/fully_kiosk/test_switch.py b/tests/components/fully_kiosk/test_switch.py index 8da01ff2fe9..20b5ed11998 100644 --- a/tests/components/fully_kiosk/test_switch.py +++ b/tests/components/fully_kiosk/test_switch.py @@ -7,16 +7,18 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_mqtt_message +from tests.typing import MqttMockHAClient async def test_switches( - hass: HomeAssistant, mock_fully_kiosk: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_fully_kiosk: MagicMock, + init_integration: MockConfigEntry, ) -> None: """Test Fully Kiosk switches.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - entity = hass.states.get("switch.amazon_fire_screensaver") assert entity assert entity.state == "off" @@ -85,6 +87,51 @@ async def test_switches( assert device_entry.sw_version == "1.42.5" +async def test_switches_mqtt_update( + hass: HomeAssistant, + mock_fully_kiosk: MagicMock, + mqtt_mock: MqttMockHAClient, + init_integration: MockConfigEntry, +) -> None: + """Test push updates over MQTT.""" + assert has_subscribed(mqtt_mock, "fully/event/onScreensaverStart/abcdef-123456") + assert has_subscribed(mqtt_mock, "fully/event/onScreensaverStop/abcdef-123456") + assert has_subscribed(mqtt_mock, "fully/event/screenOff/abcdef-123456") + assert has_subscribed(mqtt_mock, "fully/event/screenOn/abcdef-123456") + + entity = hass.states.get("switch.amazon_fire_screensaver") + assert entity + assert entity.state == "off" + + entity = hass.states.get("switch.amazon_fire_screen") + assert entity + assert entity.state == "on" + + async_fire_mqtt_message(hass, "fully/event/onScreensaverStart/abcdef-123456", "{}") + entity = hass.states.get("switch.amazon_fire_screensaver") + assert entity.state == "on" + + async_fire_mqtt_message(hass, "fully/event/onScreensaverStop/abcdef-123456", "{}") + entity = hass.states.get("switch.amazon_fire_screensaver") + assert entity.state == "off" + + async_fire_mqtt_message(hass, "fully/event/screenOff/abcdef-123456", "{}") + entity = hass.states.get("switch.amazon_fire_screen") + assert entity.state == "off" + + async_fire_mqtt_message(hass, "fully/event/screenOn/abcdef-123456", "{}") + entity = hass.states.get("switch.amazon_fire_screen") + assert entity.state == "on" + + +def has_subscribed(mqtt_mock: MqttMockHAClient, topic: str) -> bool: + """Check if MQTT topic has subscription.""" + for call in mqtt_mock.async_subscribe.call_args_list: + if call.args[0] == topic: + return True + return False + + def call_service(hass, service, entity_id): """Call any service on entity.""" return hass.services.async_call( diff --git a/tests/components/gardena_bluetooth/test_init.py b/tests/components/gardena_bluetooth/test_init.py index b09d2177c22..1f294c6169d 100644 --- a/tests/components/gardena_bluetooth/test_init.py +++ b/tests/components/gardena_bluetooth/test_init.py @@ -20,6 +20,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_setup( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_entry: MockConfigEntry, mock_read_char_raw: dict[str, bytes], snapshot: SnapshotAssertion, @@ -34,7 +35,6 @@ async def test_setup( assert mock_entry.state is ConfigEntryState.LOADED - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={(DOMAIN, WATER_TIMER_SERVICE_INFO.address)} ) diff --git a/tests/components/gdacs/test_geo_location.py b/tests/components/gdacs/test_geo_location.py index d279fe981d4..dfdce7635df 100644 --- a/tests/components/gdacs/test_geo_location.py +++ b/tests/components/gdacs/test_geo_location.py @@ -44,7 +44,7 @@ from tests.common import async_fire_time_changed CONFIG = {gdacs.DOMAIN: {CONF_RADIUS: 200}} -async def test_setup(hass: HomeAssistant) -> None: +async def test_setup(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test the general setup of the integration.""" # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry( @@ -106,7 +106,6 @@ async def test_setup(hass: HomeAssistant) -> None: + len(hass.states.async_entity_ids("sensor")) == 4 ) - entity_registry = er.async_get(hass) assert len(entity_registry.entities) == 4 state = hass.states.get("geo_location.drought_name_1") diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index aecfcbc29c1..8bfd0a66dd5 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -1,7 +1,6 @@ """The tests for generic camera component.""" import asyncio from http import HTTPStatus -import sys from unittest.mock import patch import aiohttp @@ -164,17 +163,10 @@ async def test_limit_refetch( hass.states.async_set("sensor.temp", "5") - # TODO: Remove version check with aiohttp 3.9.0 - if sys.version_info >= (3, 12): - with pytest.raises(aiohttp.ServerTimeoutError), patch( - "asyncio.timeout", side_effect=asyncio.TimeoutError() - ): - resp = await client.get("/api/camera_proxy/camera.config_test") - else: - with pytest.raises(aiohttp.ServerTimeoutError), patch( - "async_timeout.timeout", side_effect=asyncio.TimeoutError() - ): - resp = await client.get("/api/camera_proxy/camera.config_test") + with pytest.raises(aiohttp.ServerTimeoutError), patch( + "asyncio.timeout", side_effect=asyncio.TimeoutError() + ): + resp = await client.get("/api/camera_proxy/camera.config_test") assert respx.calls.call_count == 1 assert resp.status == HTTPStatus.OK diff --git a/tests/components/generic_hygrostat/test_humidifier.py b/tests/components/generic_hygrostat/test_humidifier.py index bd97a683989..9c0fa7ddaef 100644 --- a/tests/components/generic_hygrostat/test_humidifier.py +++ b/tests/components/generic_hygrostat/test_humidifier.py @@ -170,7 +170,9 @@ async def test_humidifier_switch( assert hass.states.get(ENTITY).attributes.get("action") == "humidifying" -async def test_unique_id(hass: HomeAssistant, setup_comp_1) -> None: +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, setup_comp_1 +) -> None: """Test setting a unique ID.""" unique_id = "some_unique_id" _setup_sensor(hass, 18) @@ -190,8 +192,6 @@ async def test_unique_id(hass: HomeAssistant, setup_comp_1) -> None: ) await hass.async_block_till_done() - entity_registry = er.async_get(hass) - entry = entity_registry.async_get(ENTITY) assert entry assert entry.unique_id == unique_id diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 2a406ddbd79..47a3cdc30af 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -173,7 +173,9 @@ async def test_heater_switch( assert hass.states.get(heater_switch).state == STATE_ON -async def test_unique_id(hass: HomeAssistant, setup_comp_1) -> None: +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, setup_comp_1 +) -> None: """Test setting a unique ID.""" unique_id = "some_unique_id" _setup_sensor(hass, 18) @@ -193,8 +195,6 @@ async def test_unique_id(hass: HomeAssistant, setup_comp_1) -> None: ) await hass.async_block_till_done() - entity_registry = er.async_get(hass) - entry = entity_registry.async_get(ENTITY) assert entry assert entry.unique_id == unique_id diff --git a/tests/components/geo_json_events/test_config_flow.py b/tests/components/geo_json_events/test_config_flow.py index 765f7c11482..a6e20ad4ba8 100644 --- a/tests/components/geo_json_events/test_config_flow.py +++ b/tests/components/geo_json_events/test_config_flow.py @@ -1,5 +1,4 @@ """Define tests for the GeoJSON Events config flow.""" -from datetime import timedelta import pytest @@ -10,14 +9,14 @@ from homeassistant.const import ( CONF_LOCATION, CONF_LONGITUDE, CONF_RADIUS, - CONF_SCAN_INTERVAL, CONF_URL, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .conftest import URL + from tests.common import MockConfigEntry -from tests.components.geo_json_events.conftest import URL pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -49,52 +48,6 @@ async def test_duplicate_error_user( assert result["reason"] == "already_configured" -async def test_duplicate_error_import( - hass: HomeAssistant, config_entry: MockConfigEntry -) -> None: - """Test that errors are shown when duplicates are added.""" - config_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_URL: URL, - CONF_LATITUDE: -41.2, - CONF_LONGITUDE: 174.7, - CONF_RADIUS: 25, - }, - ) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -async def test_step_import(hass: HomeAssistant) -> None: - """Test that the import step works.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_URL: URL, - CONF_LATITUDE: -41.2, - CONF_LONGITUDE: 174.7, - CONF_RADIUS: 25, - # This custom scan interval will not be carried over into the configuration. - CONF_SCAN_INTERVAL: timedelta(minutes=4), - }, - ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert ( - result["title"] == "http://geo.json.local/geo_json_events.json (-41.2, 174.7)" - ) - assert result["data"] == { - CONF_URL: URL, - CONF_LATITUDE: -41.2, - CONF_LONGITUDE: 174.7, - CONF_RADIUS: 25, - } - - async def test_step_user(hass: HomeAssistant) -> None: """Test that the user step works.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/geo_json_events/test_geo_location.py b/tests/components/geo_json_events/test_geo_location.py index ce650925200..a44357a5763 100644 --- a/tests/components/geo_json_events/test_geo_location.py +++ b/tests/components/geo_json_events/test_geo_location.py @@ -1,8 +1,7 @@ """The tests for the geojson platform.""" from datetime import timedelta -from unittest.mock import ANY, call, patch +from unittest.mock import patch -from aio_geojson_generic_client import GenericFeed from freezegun import freeze_time from homeassistant.components.geo_json_events.const import ( @@ -25,7 +24,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -44,26 +42,6 @@ CONFIG_LEGACY = { } -async def test_setup_as_legacy_platform(hass: HomeAssistant) -> None: - """Test the setup with YAML legacy configuration.""" - # Set up some mock feed entries for this test. - mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 20.5, (-31.1, 150.1)) - - with patch( - "aio_geojson_generic_client.feed_manager.GenericFeed", - wraps=GenericFeed, - ) as mock_feed, patch( - "aio_geojson_client.feed.GeoJsonFeed.update", - return_value=("OK", [mock_entry_1]), - ): - assert await async_setup_component(hass, GEO_LOCATION_DOMAIN, CONFIG_LEGACY) - await hass.async_block_till_done() - - assert len(hass.states.async_entity_ids(GEO_LOCATION_DOMAIN)) == 1 - - assert mock_feed.call_args == call(ANY, ANY, URL, filter_radius=190.0) - - async def test_entity_lifecycle( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py index 72f862f585a..d5ababaee41 100644 --- a/tests/components/geofency/test_init.py +++ b/tests/components/geofency/test_init.py @@ -184,7 +184,11 @@ async def test_data_validation(geofency_client, webhook_id) -> None: async def test_gps_enter_and_exit_home( - hass: HomeAssistant, geofency_client, webhook_id + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + geofency_client, + webhook_id, ) -> None: """Test GPS based zone enter and exit.""" url = f"/api/webhook/{webhook_id}" @@ -223,11 +227,8 @@ async def test_gps_enter_and_exit_home( ] assert current_longitude == NOT_HOME_LONGITUDE - dev_reg = dr.async_get(hass) - assert len(dev_reg.devices) == 1 - - ent_reg = er.async_get(hass) - assert len(ent_reg.entities) == 1 + assert len(device_registry.devices) == 1 + assert len(entity_registry.entities) == 1 async def test_beacon_enter_and_exit_home( diff --git a/tests/components/geonetnz_quakes/test_geo_location.py b/tests/components/geonetnz_quakes/test_geo_location.py index bfe94bbf304..561d9aaedeb 100644 --- a/tests/components/geonetnz_quakes/test_geo_location.py +++ b/tests/components/geonetnz_quakes/test_geo_location.py @@ -38,7 +38,7 @@ from tests.common import async_fire_time_changed CONFIG = {geonetnz_quakes.DOMAIN: {CONF_RADIUS: 200}} -async def test_setup(hass: HomeAssistant) -> None: +async def test_setup(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test the general setup of the integration.""" # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry( @@ -80,7 +80,6 @@ async def test_setup(hass: HomeAssistant) -> None: + len(hass.states.async_entity_ids("sensor")) == 4 ) - entity_registry = er.async_get(hass) assert len(entity_registry.entities) == 4 state = hass.states.get("geo_location.title_1") diff --git a/tests/components/gios/__init__.py b/tests/components/gios/__init__.py index 946cceac786..4e69420f66e 100644 --- a/tests/components/gios/__init__.py +++ b/tests/components/gios/__init__.py @@ -43,7 +43,8 @@ async def init_integration( "homeassistant.components.gios.Gios._get_all_sensors", return_value=sensors, ), patch( - "homeassistant.components.gios.Gios._get_indexes", return_value=indexes + "homeassistant.components.gios.Gios._get_indexes", + return_value=indexes, ): entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/gios/test_config_flow.py b/tests/components/gios/test_config_flow.py index 3d52c122791..efe46be9b8d 100644 --- a/tests/components/gios/test_config_flow.py +++ b/tests/components/gios/test_config_flow.py @@ -55,7 +55,8 @@ async def test_invalid_sensor_data(hass: HomeAssistant) -> None: "homeassistant.components.gios.Gios._get_station", return_value=json.loads(load_fixture("gios/station.json")), ), patch( - "homeassistant.components.gios.Gios._get_sensor", return_value={} + "homeassistant.components.gios.Gios._get_sensor", + return_value={}, ): flow = config_flow.GiosFlowHandler() flow.hass = hass @@ -83,7 +84,8 @@ async def test_cannot_connect(hass: HomeAssistant) -> None: async def test_create_entry(hass: HomeAssistant) -> None: """Test that the user step works.""" with patch( - "homeassistant.components.gios.Gios._get_stations", return_value=STATIONS + "homeassistant.components.gios.Gios._get_stations", + return_value=STATIONS, ), patch( "homeassistant.components.gios.Gios._get_station", return_value=json.loads(load_fixture("gios/station.json")), diff --git a/tests/components/gios/test_init.py b/tests/components/gios/test_init.py index ab73fc1e75f..d20aecad3df 100644 --- a/tests/components/gios/test_init.py +++ b/tests/components/gios/test_init.py @@ -82,9 +82,7 @@ async def test_migrate_device_and_config_entry( ), patch( "homeassistant.components.gios.Gios._get_all_sensors", return_value=sensors, - ), patch( - "homeassistant.components.gios.Gios._get_indexes", return_value=indexes - ): + ), patch("homeassistant.components.gios.Gios._get_indexes", return_value=indexes): config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( @@ -100,11 +98,11 @@ async def test_migrate_device_and_config_entry( assert device_entry.id == migrated_device_entry.id -async def test_remove_air_quality_entities(hass: HomeAssistant) -> None: +async def test_remove_air_quality_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test remove air_quality entities from registry.""" - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( AIR_QUALITY_PLATFORM, DOMAIN, "123", @@ -114,5 +112,5 @@ async def test_remove_air_quality_entities(hass: HomeAssistant) -> None: await init_integration(hass) - entry = registry.async_get("air_quality.home") + entry = entity_registry.async_get("air_quality.home") assert entry is None diff --git a/tests/components/gios/test_sensor.py b/tests/components/gios/test_sensor.py index 82027d2bdb9..e14b4548d86 100644 --- a/tests/components/gios/test_sensor.py +++ b/tests/components/gios/test_sensor.py @@ -30,10 +30,9 @@ from . import init_integration from tests.common import async_fire_time_changed, load_fixture -async def test_sensor(hass: HomeAssistant) -> None: +async def test_sensor(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test states of the sensor.""" await init_integration(hass) - registry = er.async_get(hass) state = hass.states.get("sensor.home_benzene") assert state @@ -46,7 +45,7 @@ async def test_sensor(hass: HomeAssistant) -> None: ) assert state.attributes.get(ATTR_ICON) == "mdi:molecule" - entry = registry.async_get("sensor.home_benzene") + entry = entity_registry.async_get("sensor.home_benzene") assert entry assert entry.unique_id == "123-c6h6" @@ -61,7 +60,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_carbon_monoxide") + entry = entity_registry.async_get("sensor.home_carbon_monoxide") assert entry assert entry.unique_id == "123-co" @@ -76,7 +75,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_nitrogen_dioxide") + entry = entity_registry.async_get("sensor.home_nitrogen_dioxide") assert entry assert entry.unique_id == "123-no2" @@ -94,7 +93,7 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_nitrogen_dioxide_index") + entry = entity_registry.async_get("sensor.home_nitrogen_dioxide_index") assert entry assert entry.unique_id == "123-no2-index" @@ -109,7 +108,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_ozone") + entry = entity_registry.async_get("sensor.home_ozone") assert entry assert entry.unique_id == "123-o3" @@ -127,7 +126,7 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_ozone_index") + entry = entity_registry.async_get("sensor.home_ozone_index") assert entry assert entry.unique_id == "123-o3-index" @@ -142,7 +141,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_pm10") + entry = entity_registry.async_get("sensor.home_pm10") assert entry assert entry.unique_id == "123-pm10" @@ -160,7 +159,7 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_pm10_index") + entry = entity_registry.async_get("sensor.home_pm10_index") assert entry assert entry.unique_id == "123-pm10-index" @@ -175,7 +174,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_pm2_5") + entry = entity_registry.async_get("sensor.home_pm2_5") assert entry assert entry.unique_id == "123-pm25" @@ -193,7 +192,7 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_pm2_5_index") + entry = entity_registry.async_get("sensor.home_pm2_5_index") assert entry assert entry.unique_id == "123-pm25-index" @@ -208,7 +207,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_sulphur_dioxide") + entry = entity_registry.async_get("sensor.home_sulphur_dioxide") assert entry assert entry.unique_id == "123-so2" @@ -226,7 +225,7 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_sulphur_dioxide_index") + entry = entity_registry.async_get("sensor.home_sulphur_dioxide_index") assert entry assert entry.unique_id == "123-so2-index" @@ -245,7 +244,7 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_air_quality_index") + entry = entity_registry.async_get("sensor.home_air_quality_index") assert entry assert entry.unique_id == "123-aqi" @@ -365,11 +364,11 @@ async def test_invalid_indexes(hass: HomeAssistant) -> None: assert state is None -async def test_unique_id_migration(hass: HomeAssistant) -> None: +async def test_unique_id_migration( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test states of the unique_id migration.""" - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( PLATFORM, DOMAIN, "123-pm2.5", @@ -379,6 +378,6 @@ async def test_unique_id_migration(hass: HomeAssistant) -> None: await init_integration(hass) - entry = registry.async_get("sensor.home_pm2_5") + entry = entity_registry.async_get("sensor.home_pm2_5") assert entry assert entry.unique_id == "123-pm25" diff --git a/tests/components/github/test_init.py b/tests/components/github/test_init.py index f4557632d60..612c6579639 100644 --- a/tests/components/github/test_init.py +++ b/tests/components/github/test_init.py @@ -15,6 +15,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_device_registry_cleanup( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, caplog: pytest.LogCaptureFixture, @@ -23,7 +24,6 @@ async def test_device_registry_cleanup( mock_config_entry.options = {CONF_REPOSITORIES: ["home-assistant/core"]} await setup_github_integration(hass, mock_config_entry, aioclient_mock) - device_registry = dr.async_get(hass) devices = dr.async_entries_for_config_entry( registry=device_registry, config_entry_id=mock_config_entry.entry_id, diff --git a/tests/components/glances/test_sensor.py b/tests/components/glances/test_sensor.py index d7705854720..095c034abe0 100644 --- a/tests/components/glances/test_sensor.py +++ b/tests/components/glances/test_sensor.py @@ -61,16 +61,18 @@ async def test_sensor_states(hass: HomeAssistant) -> None: ], ) async def test_migrate_unique_id( - hass: HomeAssistant, object_id: str, old_unique_id: str, new_unique_id: str + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + object_id: str, + old_unique_id: str, + new_unique_id: str, ) -> None: """Test unique id migration.""" old_config_data = {**MOCK_USER_INPUT, "name": "Glances"} entry = MockConfigEntry(domain=DOMAIN, data=old_config_data) entry.add_to_hass(hass) - ent_reg = er.async_get(hass) - - entity: er.RegistryEntry = ent_reg.async_get_or_create( + entity: er.RegistryEntry = entity_registry.async_get_or_create( suggested_object_id=object_id, disabled_by=None, domain=SENSOR_DOMAIN, @@ -83,6 +85,6 @@ async def test_migrate_unique_id( assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - entity_migrated = ent_reg.async_get(entity.entity_id) + entity_migrated = entity_registry.async_get(entity.entity_id) assert entity_migrated assert entity_migrated.unique_id == f"{entry.entry_id}-{new_unique_id}" diff --git a/tests/components/goalzero/test_init.py b/tests/components/goalzero/test_init.py index 287af75c9cd..539da7e91a2 100644 --- a/tests/components/goalzero/test_init.py +++ b/tests/components/goalzero/test_init.py @@ -66,11 +66,12 @@ async def test_update_failed( async def test_device_info( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test device info.""" entry = await async_init_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index d938a2f3291..3b2ed6d24e1 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -218,7 +218,7 @@ def config_entry( domain=DOMAIN, unique_id=config_entry_unique_id, data={ - "auth_implementation": "device_auth", + "auth_implementation": DOMAIN, "token": { "access_token": "ACCESS_TOKEN", "refresh_token": "REFRESH_TOKEN", @@ -350,7 +350,9 @@ def component_setup( async def _setup_func() -> bool: assert await async_setup_component(hass, "application_credentials", {}) await async_import_client_credential( - hass, DOMAIN, ClientCredential("client-id", "client-secret"), "device_auth" + hass, + DOMAIN, + ClientCredential("client-id", "client-secret"), ) config_entry.add_to_hass(hass) return await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 83544087104..a70cd8aee9f 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -653,6 +653,7 @@ async def test_future_event_offset_update_behavior( async def test_unique_id( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_events_list_items, component_setup, config_entry, @@ -661,7 +662,6 @@ async def test_unique_id( mock_events_list_items([]) assert await component_setup() - entity_registry = er.async_get(hass) registry_entries = er.async_entries_for_config_entry( entity_registry, config_entry.entry_id ) @@ -675,14 +675,13 @@ async def test_unique_id( ) async def test_unique_id_migration( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_events_list_items, component_setup, config_entry, old_unique_id, ) -> None: """Test that old unique id format is migrated to the new format that supports multiple accounts.""" - entity_registry = er.async_get(hass) - # Create an entity using the old unique id format entity_registry.async_get_or_create( DOMAIN, @@ -730,14 +729,13 @@ async def test_unique_id_migration( ) async def test_invalid_unique_id_cleanup( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_events_list_items, component_setup, config_entry, mock_calendars_yaml, ) -> None: """Test that old unique id format that is not actually unique is removed.""" - entity_registry = er.async_get(hass) - # Create an entity using the old unique id format entity_registry.async_get_or_create( DOMAIN, diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py index aa8976bda21..f534f624bf6 100644 --- a/tests/components/google/test_config_flow.py +++ b/tests/components/google/test_config_flow.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import Callable import datetime from http import HTTPStatus +from typing import Any from unittest.mock import Mock, patch from aiohttp.client_exceptions import ClientError @@ -21,9 +22,14 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.google.const import DOMAIN +from homeassistant.components.google.const import ( + CONF_CREDENTIAL_TYPE, + DOMAIN, + CredentialType, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -31,9 +37,13 @@ from homeassistant.util.dt import utcnow from .conftest import CLIENT_ID, CLIENT_SECRET, EMAIL_ADDRESS, YieldFixture from tests.common import MockConfigEntry, async_fire_time_changed +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator CODE_CHECK_INTERVAL = 1 CODE_CHECK_ALARM_TIMEDELTA = datetime.timedelta(seconds=CODE_CHECK_INTERVAL * 2) +OAUTH2_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth" +OAUTH2_TOKEN = "https://oauth2.googleapis.com/token" @pytest.fixture(autouse=True) @@ -175,6 +185,7 @@ async def test_full_flow_application_creds( "scope": "https://www.googleapis.com/auth/calendar", "token_type": "Bearer", }, + "credential_type": "device_auth", } assert result.get("options") == {"calendar_access": "read_write"} @@ -230,7 +241,9 @@ async def test_expired_after_exchange( ) -> None: """Test credential exchange expires.""" await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred" + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), ) result = await hass.config_entries.flow.async_init( @@ -262,7 +275,9 @@ async def test_exchange_error( ) -> None: """Test an error while exchanging the code for credentials.""" await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "device_auth" + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), ) result = await hass.config_entries.flow.async_init( @@ -307,13 +322,14 @@ async def test_exchange_error( data["token"].pop("expires_at") data["token"].pop("expires_in") assert data == { - "auth_implementation": "device_auth", + "auth_implementation": DOMAIN, "token": { "access_token": "ACCESS_TOKEN", "refresh_token": "REFRESH_TOKEN", "scope": "https://www.googleapis.com/auth/calendar", "token_type": "Bearer", }, + "credential_type": "device_auth", } assert len(mock_setup.mock_calls) == 1 @@ -329,7 +345,7 @@ async def test_duplicate_config_entries( ) -> None: """Test that the same account cannot be setup twice.""" await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred" + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) ) # Load a config entry @@ -371,7 +387,7 @@ async def test_multiple_config_entries( ) -> None: """Test that multiple config entries can be set at once.""" await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred" + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) ) # Load a config entry @@ -455,17 +471,19 @@ async def test_reauth_flow( mock_code_flow: Mock, mock_exchange: Mock, ) -> None: - """Test can't configure when config entry already exists.""" + """Test reauth of an existing config entry.""" config_entry = MockConfigEntry( domain=DOMAIN, data={ - "auth_implementation": "device_auth", + "auth_implementation": DOMAIN, "token": {"access_token": "OLD_ACCESS_TOKEN"}, }, ) config_entry.add_to_hass(hass) await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "device_auth" + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), ) entries = hass.config_entries.async_entries(DOMAIN) @@ -512,13 +530,14 @@ async def test_reauth_flow( data["token"].pop("expires_at") data["token"].pop("expires_in") assert data == { - "auth_implementation": "device_auth", + "auth_implementation": DOMAIN, "token": { "access_token": "ACCESS_TOKEN", "refresh_token": "REFRESH_TOKEN", "scope": "https://www.googleapis.com/auth/calendar", "token_type": "Bearer", }, + "credential_type": "device_auth", } assert len(mock_setup.mock_calls) == 1 @@ -540,7 +559,9 @@ async def test_calendar_lookup_failure( ) -> None: """Test successful config flow and title fetch fails gracefully.""" await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "device_auth" + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), ) result = await hass.config_entries.flow.async_init( @@ -624,3 +645,189 @@ async def test_options_flow_no_changes( ) assert result["type"] == "create_entry" assert config_entry.options == {"calendar_access": "read_write"} + + +async def test_web_auth_compatibility( + hass: HomeAssistant, + current_request_with_host: None, + mock_code_flow: Mock, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test that we can callback to web auth tokens.""" + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + with patch( + "homeassistant.components.google.api.OAuth2WebServerFlow.step1_get_device_and_user_codes", + side_effect=OAuth2DeviceCodeError( + "Invalid response 401. Error: invalid_client" + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["type"] == "external" + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/calendar" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": "https://www.googleapis.com/auth/calendar", + }, + ) + + with patch( + "homeassistant.components.google.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.CREATE_ENTRY + token = result.get("data", {}).get("token", {}) + del token["expires_at"] + assert token == { + "access_token": "mock-access-token", + "expires_in": 60, + "refresh_token": "mock-refresh-token", + "type": "Bearer", + "scope": "https://www.googleapis.com/auth/calendar", + } + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + +@pytest.mark.parametrize( + "entry_data", + [ + {}, + {CONF_CREDENTIAL_TYPE: CredentialType.WEB_AUTH}, + ], +) +async def test_web_reauth_flow( + hass: HomeAssistant, + mock_code_flow: Mock, + mock_exchange: Mock, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, + entry_data: dict[str, Any], +) -> None: + """Test reauth of an existing config entry with a web credential.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + **entry_data, + "auth_implementation": DOMAIN, + "token": {"access_token": "OLD_ACCESS_TOKEN"}, + }, + ) + config_entry.add_to_hass(hass) + await async_import_client_credential( + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) + ) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.google.api.OAuth2WebServerFlow.step1_get_device_and_user_codes", + side_effect=OAuth2DeviceCodeError( + "Invalid response 401. Error: invalid_client" + ), + ): + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input={}, + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result.get("type") == "external" + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/calendar" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 60, + "scope": "https://www.googleapis.com/auth/calendar", + }, + ) + + with patch( + "homeassistant.components.google.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + data = dict(entries[0].data) + data["token"].pop("expires_at") + data["token"].pop("expires_in") + assert data == { + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "scope": "https://www.googleapis.com/auth/calendar", + "token_type": "Bearer", + }, + "credential_type": "web_auth", + } + + assert len(mock_setup.mock_calls) == 1 diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 57915968933..aaa3949caaf 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -14,14 +14,17 @@ from homeassistant.components.google_assistant.const import ( SOURCE_LOCAL, STORE_GOOGLE_LOCAL_WEBHOOK_ID, ) +from homeassistant.components.matter.models import MatterDeviceInfo from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from . import MockConfig from tests.common import ( + MockConfigEntry, async_capture_events, async_fire_time_changed, async_mock_service, @@ -73,6 +76,57 @@ async def test_google_entity_sync_serialize_with_local_sdk(hass: HomeAssistant) assert "customData" not in serialized +async def test_google_entity_sync_serialize_with_matter( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test sync serialize attributes of a GoogleEntity that is also a Matter device.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + manufacturer="Someone", + model="Some model", + sw_version="Some Version", + identifiers={("matter", "12345678")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity = entity_registry.async_get_or_create( + "light", + "test", + "1235", + suggested_object_id="ceiling_lights", + device_id=device.id, + ) + hass.states.async_set("light.ceiling_lights", "off") + + entity = helpers.GoogleEntity( + hass, MockConfig(hass=hass), hass.states.get("light.ceiling_lights") + ) + + serialized = entity.sync_serialize(None, "mock-uuid") + assert "matterUniqueId" not in serialized + assert "matterOriginalVendorId" not in serialized + assert "matterOriginalProductId" not in serialized + + hass.config.components.add("matter") + + with patch( + "homeassistant.components.matter.get_matter_device_info", + return_value=MatterDeviceInfo( + unique_id="mock-unique-id", + vendor_id="mock-vendor-id", + product_id="mock-product-id", + ), + ): + serialized = entity.sync_serialize("mock-user-id", "abcdef") + + assert serialized["matterUniqueId"] == "mock-unique-id" + assert serialized["matterOriginalVendorId"] == "mock-vendor-id" + assert serialized["matterOriginalProductId"] == "mock-product-id" + + async def test_config_local_sdk( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: diff --git a/tests/components/google_assistant/test_http.py b/tests/components/google_assistant/test_http.py index 62d2722c445..aa7f8472cab 100644 --- a/tests/components/google_assistant/test_http.py +++ b/tests/components/google_assistant/test_http.py @@ -92,7 +92,7 @@ async def test_update_access_token(hass: HomeAssistant) -> None: ) as mock_get_token, patch( "homeassistant.components.google_assistant.http._get_homegraph_jwt" ) as mock_get_jwt, patch( - "homeassistant.core.dt_util.utcnow" + "homeassistant.core.dt_util.utcnow", ) as mock_utcnow: mock_utcnow.return_value = base_time mock_get_jwt.return_value = jwt diff --git a/tests/components/google_assistant_sdk/test_notify.py b/tests/components/google_assistant_sdk/test_notify.py index f35d19e3805..cf3f90097ce 100644 --- a/tests/components/google_assistant_sdk/test_notify.py +++ b/tests/components/google_assistant_sdk/test_notify.py @@ -66,7 +66,12 @@ async def test_broadcast_no_targets( "Anuncia en el salón Es hora de hacer los deberes", ), ("ko-KR", "숙제할 시간이야", "거실", "숙제할 시간이야 라고 거실에 방송해 줘"), - ("ja-JP", "宿題の時間だよ", "リビング", "宿題の時間だよとリビングにブロードキャストして"), + ( + "ja-JP", + "宿題の時間だよ", + "リビング", + "宿題の時間だよとリビングにブロードキャストして", + ), ], ids=["english", "spanish", "korean", "japanese"], ) diff --git a/tests/components/google_mail/test_init.py b/tests/components/google_mail/test_init.py index 4882fd10e80..ef2f1475dad 100644 --- a/tests/components/google_mail/test_init.py +++ b/tests/components/google_mail/test_init.py @@ -121,11 +121,12 @@ async def test_expired_token_refresh_client_error( async def test_device_info( - hass: HomeAssistant, setup_integration: ComponentSetup + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + setup_integration: ComponentSetup, ) -> None: """Test device info.""" await setup_integration() - device_registry = dr.async_get(hass) entry = hass.config_entries.async_entries(DOMAIN)[0] device = device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) diff --git a/tests/components/google_tasks/snapshots/test_todo.ambr b/tests/components/google_tasks/snapshots/test_todo.ambr index f24d17a60d1..7d6eb920593 100644 --- a/tests/components/google_tasks/snapshots/test_todo.ambr +++ b/tests/components/google_tasks/snapshots/test_todo.ambr @@ -1,13 +1,88 @@ # serializer version: 1 -# name: test_create_todo_list_item[api_responses0] +# name: test_create_todo_list_item[description] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json', 'POST', ) # --- -# name: test_create_todo_list_item[api_responses0].1 +# name: test_create_todo_list_item[description].1 + '{"title": "Soda", "status": "needsAction", "notes": "6-pack"}' +# --- +# name: test_create_todo_list_item[due] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json', + 'POST', + ) +# --- +# name: test_create_todo_list_item[due].1 + '{"title": "Soda", "status": "needsAction", "due": "2023-11-18T00:00:00-08:00"}' +# --- +# name: test_create_todo_list_item[summary] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json', + 'POST', + ) +# --- +# name: test_create_todo_list_item[summary].1 '{"title": "Soda", "status": "needsAction"}' # --- +# name: test_delete_todo_list_item[_handler] + tuple( + 'https://tasks.googleapis.com/batch', + 'POST', + ) +# --- +# name: test_parent_child_ordering[api_responses0] + list([ + dict({ + 'status': 'needs_action', + 'summary': 'Task 1', + 'uid': 'task-1', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Task 2', + 'uid': 'task-2', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Task 3 (Parent)', + 'uid': 'task-3', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Task 4', + 'uid': 'task-4', + }), + ]) +# --- +# name: test_partial_update[description] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[description].1 + '{"notes": "6-pack"}' +# --- +# name: test_partial_update[due_date] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[due_date].1 + '{"due": "2023-11-18T00:00:00-08:00"}' +# --- +# name: test_partial_update[rename] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[rename].1 + '{"title": "Soda"}' +# --- # name: test_partial_update_status[api_responses0] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', @@ -17,15 +92,6 @@ # name: test_partial_update_status[api_responses0].1 '{"status": "needsAction"}' # --- -# name: test_partial_update_title[api_responses0] - tuple( - 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', - 'PATCH', - ) -# --- -# name: test_partial_update_title[api_responses0].1 - '{"title": "Soda"}' -# --- # 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_todo.py b/tests/components/google_tasks/test_todo.py index e19ac1272cd..3329f89c1ca 100644 --- a/tests/components/google_tasks/test_todo.py +++ b/tests/components/google_tasks/test_todo.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable +from http import HTTPStatus import json from typing import Any from unittest.mock import Mock, patch @@ -13,28 +14,80 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from tests.typing import WebSocketGenerator ENTITY_ID = "todo.my_tasks" +ITEM = { + "id": "task-list-id-1", + "title": "My tasks", +} LIST_TASK_LIST_RESPONSE = { - "items": [ - { - "id": "task-list-id-1", - "title": "My tasks", - }, - ] + "items": [ITEM], } EMPTY_RESPONSE = {} LIST_TASKS_RESPONSE = { "items": [], } +ERROR_RESPONSE = { + "error": { + "code": 400, + "message": "Invalid task ID", + "errors": [ + {"message": "Invalid task ID", "domain": "global", "reason": "invalid"} + ], + } +} +CONTENT_ID = "Content-ID" +BOUNDARY = "batch_00972cc8-75bd-11ee-9692-0242ac110002" # Arbitrary uuid LIST_TASKS_RESPONSE_WATER = { "items": [ - {"id": "some-task-id", "title": "Water", "status": "needsAction"}, + { + "id": "some-task-id", + "title": "Water", + "status": "needsAction", + "position": "00000000000000000001", + }, ], } +LIST_TASKS_RESPONSE_MULTIPLE = { + "items": [ + { + "id": "some-task-id-2", + "title": "Milk", + "status": "needsAction", + "position": "00000000000000000002", + }, + { + "id": "some-task-id-1", + "title": "Water", + "status": "needsAction", + "position": "00000000000000000001", + }, + { + "id": "some-task-id-3", + "title": "Cheese", + "status": "needsAction", + "position": "00000000000000000003", + }, + ], +} + +# API responses when testing update methods +UPDATE_API_RESPONSES = [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_WATER, + EMPTY_RESPONSE, # update + LIST_TASKS_RESPONSE, # refresh after update +] +CREATE_API_RESPONSES = [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE, + EMPTY_RESPONSE, # create + LIST_TASKS_RESPONSE, # refresh +] @pytest.fixture @@ -43,39 +96,22 @@ def platforms() -> list[str]: return [Platform.TODO] -@pytest.fixture -def ws_req_id() -> Callable[[], int]: - """Fixture for incremental websocket requests.""" - - id = 0 - - def next_id() -> int: - nonlocal id - id += 1 - return id - - return next_id - - @pytest.fixture async def ws_get_items( - hass_ws_client: WebSocketGenerator, ws_req_id: Callable[[], int] + hass_ws_client: WebSocketGenerator, ) -> Callable[[], Awaitable[dict[str, str]]]: """Fixture to fetch items from the todo websocket.""" async def get() -> list[dict[str, str]]: # Fetch items using To-do platform client = await hass_ws_client() - id = ws_req_id() - await client.send_json( + await client.send_json_auto_id( { - "id": id, "type": "todo/item/list", "entity_id": ENTITY_ID, } ) resp = await client.receive_json() - assert resp.get("id") == id assert resp.get("success") return resp.get("result", {}).get("items", []) @@ -88,14 +124,87 @@ def mock_api_responses() -> list[dict | list]: 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] +) -> tuple[Response, bytes]: + """Create a batch response in the multipart/mixed format.""" + assert len(api_responses) == len(content_ids) + content = [] + for api_response in api_responses: + status = 200 + body = "" + if isinstance(api_response, Response): + status = api_response.status + else: + body = json.dumps(api_response) + content.extend( + [ + f"--{BOUNDARY}", + "Content-Type: application/http", + f"{CONTENT_ID}: {content_ids.pop()}", + "", + f"HTTP/1.1 {status} OK", + "Content-Type: application/json; charset=UTF-8", + "", + body, + ] + ) + content.append(f"--{BOUNDARY}--") + body = ("\r\n".join(content)).encode() + return ( + Response( + { + "Content-Type": f"multipart/mixed; boundary={BOUNDARY}", + "Content-ID": "1", + } + ), + body, + ) + + +def create_batch_response_handler( + api_responses: list[dict | list | Response], +) -> Callable[[Any], tuple[Response, bytes]]: + """Create a fake http2lib response handler that supports generating batch responses. + + Multi-part response objects are dynamically generated since they + need to match the Content-ID of the incoming request. + """ + + def _handler(url, method, **kwargs) -> tuple[Response, bytes]: + next_api_response = api_responses.pop(0) + if method == "POST" and (body := kwargs.get("body")): + content_ids = [ + line[len(CONTENT_ID) + 2 :] + for line in body.splitlines() + if line.startswith(f"{CONTENT_ID}:") + ] + if content_ids: + return create_batch_response_object(content_ids, next_api_response) + return create_response_object(next_api_response) + + 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(api_responses: list[dict | list]) -> Mock: +def mock_http_response(response_handler: list | Callable) -> Mock: """Fixture to fake out http2lib responses.""" - responses = [ - (Response({}), bytes(json.dumps(api_response), encoding="utf-8")) - for api_response in api_responses - ] - with patch("httplib2.Http.request", side_effect=responses) as mock_response: + + with patch("httplib2.Http.request", side_effect=response_handler) as mock_response: yield mock_response @@ -106,8 +215,20 @@ def mock_http_response(api_responses: list[dict | list]) -> Mock: LIST_TASK_LIST_RESPONSE, { "items": [ - {"id": "task-1", "title": "Task 1", "status": "needsAction"}, - {"id": "task-2", "title": "Task 2", "status": "completed"}, + { + "id": "task-1", + "title": "Task 1", + "status": "needsAction", + "position": "0000000000000001", + "due": "2023-11-18T00:00:00+00:00", + }, + { + "id": "task-2", + "title": "Task 2", + "status": "completed", + "position": "0000000000000002", + "notes": "long description", + }, ], }, ] @@ -132,11 +253,13 @@ async def test_get_items( "uid": "task-1", "summary": "Task 1", "status": "needs_action", + "due": "2023-11-18", }, { "uid": "task-2", "summary": "Task 2", "status": "completed", + "description": "long description", }, ] @@ -146,6 +269,29 @@ 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", [ @@ -181,17 +327,43 @@ async def test_empty_todo_list( [ [ LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE, - EMPTY_RESPONSE, # create - LIST_TASKS_RESPONSE, # refresh after create + ERROR_RESPONSE, ] ], ) +async def test_task_items_error_response( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + hass_ws_client: WebSocketGenerator, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test an error while getting todo list items.""" + + assert await integration_setup() + + await hass_ws_client(hass) + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "unavailable" + + +@pytest.mark.parametrize( + ("api_responses", "item_data"), + [ + (CREATE_API_RESPONSES, {}), + (CREATE_API_RESPONSES, {"due_date": "2023-11-18"}), + (CREATE_API_RESPONSES, {"description": "6-pack"}), + ], + ids=["summary", "due", "description"], +) async def test_create_todo_list_item( hass: HomeAssistant, setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], mock_http_response: Mock, + item_data: dict[str, Any], snapshot: SnapshotAssertion, ) -> None: """Test for creating a To-do Item.""" @@ -205,7 +377,7 @@ async def test_create_todo_list_item( await hass.services.async_call( TODO_DOMAIN, "add_item", - {"item": "Soda"}, + {"item": "Soda", **item_data}, target={"entity_id": "todo.my_tasks"}, blocking=True, ) @@ -222,11 +394,36 @@ async def test_create_todo_list_item( [ LIST_TASK_LIST_RESPONSE, LIST_TASKS_RESPONSE_WATER, - EMPTY_RESPONSE, # update - LIST_TASKS_RESPONSE, # refresh after update + ERROR_RESPONSE, ] ], ) +async def test_create_todo_list_item_error( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Mock, + snapshot: SnapshotAssertion, +) -> None: + """Test for an error response when creating a To-do Item.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "1" + + with pytest.raises(HomeAssistantError, match="Invalid task ID"): + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "Soda"}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + + +@pytest.mark.parametrize("api_responses", [UPDATE_API_RESPONSES]) async def test_update_todo_list_item( hass: HomeAssistant, setup_credentials: None, @@ -262,17 +459,51 @@ async def test_update_todo_list_item( [ LIST_TASK_LIST_RESPONSE, LIST_TASKS_RESPONSE_WATER, - EMPTY_RESPONSE, # update - LIST_TASKS_RESPONSE, # refresh after update + ERROR_RESPONSE, # update fails ] ], ) -async def test_partial_update_title( +async def test_update_todo_list_item_error( hass: HomeAssistant, setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], mock_http_response: Any, snapshot: SnapshotAssertion, +) -> None: + """Test for an error response when updating a To-do Item.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "1" + + with pytest.raises(HomeAssistantError, match="Invalid task ID"): + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": "some-task-id", "rename": "Soda", "status": "completed"}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("api_responses", "item_data"), + [ + (UPDATE_API_RESPONSES, {"rename": "Soda"}), + (UPDATE_API_RESPONSES, {"due_date": "2023-11-18"}), + (UPDATE_API_RESPONSES, {"description": "6-pack"}), + ], + ids=("rename", "due_date", "description"), +) +async def test_partial_update( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Any, + item_data: dict[str, Any], + snapshot: SnapshotAssertion, ) -> None: """Test for partial update with title only.""" @@ -285,7 +516,7 @@ async def test_partial_update_title( await hass.services.async_call( TODO_DOMAIN, "update_item", - {"item": "some-task-id", "rename": "Soda"}, + {"item": "some-task-id", **item_data}, target={"entity_id": "todo.my_tasks"}, blocking=True, ) @@ -296,17 +527,7 @@ async def test_partial_update_title( assert call.kwargs.get("body") == snapshot -@pytest.mark.parametrize( - "api_responses", - [ - [ - LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE_WATER, - EMPTY_RESPONSE, # update - LIST_TASKS_RESPONSE, # refresh after update - ] - ], -) +@pytest.mark.parametrize("api_responses", [UPDATE_API_RESPONSES]) async def test_partial_update_status( hass: HomeAssistant, setup_credentials: None, @@ -334,3 +555,315 @@ async def test_partial_update_status( assert call assert call.args == snapshot assert call.kwargs.get("body") == snapshot + + +@pytest.mark.parametrize( + "response_handler", + [ + ( + create_batch_response_handler( + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_MULTIPLE, + [EMPTY_RESPONSE, EMPTY_RESPONSE, EMPTY_RESPONSE], # Delete batch + LIST_TASKS_RESPONSE, # refresh after delete + ] + ) + ) + ], +) +async def test_delete_todo_list_item( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Any, + snapshot: SnapshotAssertion, +) -> None: + """Test for deleting multiple To-do Items.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "3" + + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": ["some-task-id-1", "some-task-id-2", "some-task-id-3"]}, + target={"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 + + +@pytest.mark.parametrize( + "response_handler", + [ + ( + create_batch_response_handler( + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_MULTIPLE, + [ + EMPTY_RESPONSE, + ERROR_RESPONSE, # one item is a failure + EMPTY_RESPONSE, + ], + LIST_TASKS_RESPONSE, # refresh after create + ] + ) + ) + ], +) +async def test_delete_partial_failure( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Any, + snapshot: SnapshotAssertion, +) -> None: + """Test for partial failure when deleting multiple To-do Items.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "3" + + with pytest.raises(HomeAssistantError, match="Invalid task ID"): + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": ["some-task-id-1", "some-task-id-2", "some-task-id-3"]}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + "response_handler", + [ + ( + create_batch_response_handler( + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_MULTIPLE, + [ + "1234-invalid-json", + ], + ] + ) + ) + ], +) +async def test_delete_invalid_json_response( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Any, + snapshot: SnapshotAssertion, +) -> None: + """Test delete with an invalid json response.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "3" + + with pytest.raises(HomeAssistantError, match="unexpected response"): + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": ["some-task-id-1"]}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + "response_handler", + [ + ( + create_batch_response_handler( + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_MULTIPLE, + [Response({"status": HTTPStatus.INTERNAL_SERVER_ERROR})], + ] + ) + ) + ], +) +async def test_delete_server_error( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Any, + snapshot: SnapshotAssertion, +) -> None: + """Test delete with an invalid json response.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "3" + + with pytest.raises(HomeAssistantError, match="responded with error"): + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": ["some-task-id-1"]}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + { + "items": [ + { + "id": "task-3-2", + "title": "Child 2", + "status": "needsAction", + "parent": "task-3", + "position": "0000000000000002", + }, + { + "id": "task-3", + "title": "Task 3 (Parent)", + "status": "needsAction", + "position": "0000000000000003", + }, + { + "id": "task-2", + "title": "Task 2", + "status": "needsAction", + "position": "0000000000000002", + }, + { + "id": "task-1", + "title": "Task 1", + "status": "needsAction", + "position": "0000000000000001", + }, + { + "id": "task-3-1", + "title": "Child 1", + "status": "needsAction", + "parent": "task-3", + "position": "0000000000000001", + }, + { + "id": "task-4", + "title": "Task 4", + "status": "needsAction", + "position": "0000000000000004", + }, + ], + }, + ] + ], +) +async def test_parent_child_ordering( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + ws_get_items: Callable[[], Awaitable[dict[str, str]]], + snapshot: SnapshotAssertion, +) -> None: + """Test getting todo list items.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "4" + + items = await ws_get_items() + assert items == snapshot + + +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_WATER, + EMPTY_RESPONSE, # update + # refresh after update + { + "items": [ + { + "id": "some-task-id", + "title": "Milk", + "status": "needsAction", + "position": "0000000000000001", + }, + ], + }, + ] + ], +) +async def test_susbcribe( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + hass_ws_client: WebSocketGenerator, +) -> None: + """Test subscribing to item updates.""" + + assert await integration_setup() + + # Subscribe and get the initial list + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "todo/item/subscribe", + "entity_id": "todo.my_tasks", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "Water" + assert items[0]["status"] == "needs_action" + uid = items[0]["uid"] + assert uid + + # Rename item + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": uid, "rename": "Milk"}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + + # Verify update is published + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "Milk" + assert items[0]["status"] == "needs_action" + assert "uid" in items[0] diff --git a/tests/components/google_translate/test_tts.py b/tests/components/google_translate/test_tts.py index d6669ee3c5f..fd1ddd8a4f2 100644 --- a/tests/components/google_translate/test_tts.py +++ b/tests/components/google_translate/test_tts.py @@ -2,13 +2,14 @@ from __future__ import annotations from collections.abc import Generator +from http import HTTPStatus from typing import Any from unittest.mock import MagicMock, patch from gtts import gTTSError import pytest -from homeassistant.components import media_source, tts +from homeassistant.components import tts from homeassistant.components.google_translate.const import CONF_TLD, DOMAIN from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, @@ -18,10 +19,11 @@ from homeassistant.components.media_player import ( from homeassistant.config import async_process_ha_core_config from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, async_mock_service +from tests.components.tts.common import retrieve_media +from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) @@ -35,15 +37,6 @@ def mock_tts_cache_dir_autouse(mock_tts_cache_dir): return mock_tts_cache_dir -async def get_media_source_url(hass: HomeAssistant, media_content_id: str) -> str: - """Get the media source url.""" - if media_source.DOMAIN not in hass.config.components: - assert await async_setup_component(hass, media_source.DOMAIN, {}) - - resolved = await media_source.async_resolve_media(hass, media_content_id, None) - return resolved.url - - @pytest.fixture async def calls(hass: HomeAssistant) -> list[ServiceCall]: """Mock media player calls.""" @@ -128,6 +121,7 @@ async def mock_config_entry_setup(hass: HomeAssistant, config: dict[str, Any]) - async def test_tts_service( hass: HomeAssistant, mock_gtts: MagicMock, + hass_client: ClientSessionGenerator, calls: list[ServiceCall], setup: str, tts_service: str, @@ -142,9 +136,11 @@ async def test_tts_service( ) assert len(calls) == 1 - url = await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(mock_gtts.mock_calls) == 2 - assert url.endswith(".mp3") assert mock_gtts.mock_calls[0][2] == { "text": "There is a person at the front door.", @@ -180,6 +176,7 @@ async def test_tts_service( async def test_service_say_german_config( hass: HomeAssistant, mock_gtts: MagicMock, + hass_client: ClientSessionGenerator, calls: list[ServiceCall], setup: str, tts_service: str, @@ -194,7 +191,10 @@ async def test_service_say_german_config( ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(mock_gtts.mock_calls) == 2 assert mock_gtts.mock_calls[0][2] == { "text": "There is a person at the front door.", @@ -231,6 +231,7 @@ async def test_service_say_german_config( async def test_service_say_german_service( hass: HomeAssistant, mock_gtts: MagicMock, + hass_client: ClientSessionGenerator, calls: list[ServiceCall], setup: str, tts_service: str, @@ -245,7 +246,10 @@ async def test_service_say_german_service( ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(mock_gtts.mock_calls) == 2 assert mock_gtts.mock_calls[0][2] == { "text": "There is a person at the front door.", @@ -281,6 +285,7 @@ async def test_service_say_german_service( async def test_service_say_en_uk_config( hass: HomeAssistant, mock_gtts: MagicMock, + hass_client: ClientSessionGenerator, calls: list[ServiceCall], setup: str, tts_service: str, @@ -295,7 +300,10 @@ async def test_service_say_en_uk_config( ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(mock_gtts.mock_calls) == 2 assert mock_gtts.mock_calls[0][2] == { "text": "There is a person at the front door.", @@ -332,6 +340,7 @@ async def test_service_say_en_uk_config( async def test_service_say_en_uk_service( hass: HomeAssistant, mock_gtts: MagicMock, + hass_client: ClientSessionGenerator, calls: list[ServiceCall], setup: str, tts_service: str, @@ -346,7 +355,10 @@ async def test_service_say_en_uk_service( ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(mock_gtts.mock_calls) == 2 assert mock_gtts.mock_calls[0][2] == { "text": "There is a person at the front door.", @@ -383,6 +395,7 @@ async def test_service_say_en_uk_service( async def test_service_say_en_couk( hass: HomeAssistant, mock_gtts: MagicMock, + hass_client: ClientSessionGenerator, calls: list[ServiceCall], setup: str, tts_service: str, @@ -397,9 +410,11 @@ async def test_service_say_en_couk( ) assert len(calls) == 1 - url = await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(mock_gtts.mock_calls) == 2 - assert url.endswith(".mp3") assert mock_gtts.mock_calls[0][2] == { "text": "There is a person at the front door.", @@ -434,6 +449,7 @@ async def test_service_say_en_couk( async def test_service_say_error( hass: HomeAssistant, mock_gtts: MagicMock, + hass_client: ClientSessionGenerator, calls: list[ServiceCall], setup: str, tts_service: str, @@ -450,6 +466,8 @@ async def test_service_say_error( ) assert len(calls) == 1 - with pytest.raises(HomeAssistantError): - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.NOT_FOUND + ) assert len(mock_gtts.mock_calls) == 2 diff --git a/tests/components/gpslogger/test_init.py b/tests/components/gpslogger/test_init.py index 22c3830abf8..a9fc5312bba 100644 --- a/tests/components/gpslogger/test_init.py +++ b/tests/components/gpslogger/test_init.py @@ -100,7 +100,11 @@ async def test_missing_data(hass: HomeAssistant, gpslogger_client, webhook_id) - async def test_enter_and_exit( - hass: HomeAssistant, gpslogger_client, webhook_id + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + gpslogger_client, + webhook_id, ) -> None: """Test when there is a known zone.""" url = f"/api/webhook/{webhook_id}" @@ -131,11 +135,8 @@ async def test_enter_and_exit( state_name = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}").state assert state_name == STATE_NOT_HOME - dev_reg = dr.async_get(hass) - assert len(dev_reg.devices) == 1 - - ent_reg = er.async_get(hass) - assert len(ent_reg.entities) == 1 + assert len(device_registry.devices) == 1 + assert len(entity_registry.entities) == 1 async def test_enter_with_attrs( diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index fe64b0ee7ef..82ad75b5d28 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -34,7 +34,11 @@ from homeassistant.components.climate import ( SWING_VERTICAL, HVACMode, ) -from homeassistant.components.gree.climate import FAN_MODES_REVERSE, HVAC_MODES_REVERSE +from homeassistant.components.gree.climate import ( + FAN_MODES_REVERSE, + HVAC_MODES, + HVAC_MODES_REVERSE, +) from homeassistant.components.gree.const import FAN_MEDIUM_HIGH, FAN_MEDIUM_LOW from homeassistant.const import ( ATTR_ENTITY_ID, @@ -384,6 +388,9 @@ async def test_send_target_temperature( """Test for sending target temperature command to the device.""" hass.config.units.temperature_unit = units + device().power = True + device().mode = HVAC_MODES_REVERSE.get(HVACMode.AUTO) + fake_device = device() if units == UnitOfTemperature.FAHRENHEIT: fake_device.temperature_units = 1 @@ -407,12 +414,47 @@ async def test_send_target_temperature( state.attributes.get(ATTR_CURRENT_TEMPERATURE) == fake_device.current_temperature ) + assert state.state == HVAC_MODES.get(fake_device.mode) # Reset config temperature_unit back to CELSIUS, required for # additional tests outside this component. hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS +@pytest.mark.parametrize( + ("temperature", "hvac_mode"), + [ + (26, HVACMode.OFF), + (26, HVACMode.HEAT), + (26, HVACMode.COOL), + (26, HVACMode.AUTO), + (26, HVACMode.DRY), + (26, HVACMode.FAN_ONLY), + ], +) +async def test_send_target_temperature_with_hvac_mode( + hass: HomeAssistant, discovery, device, temperature, hvac_mode +) -> None: + """Test for sending target temperature command to the device alongside hvac mode.""" + await async_setup_gree(hass) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: temperature, + ATTR_HVAC_MODE: hvac_mode, + }, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes.get(ATTR_TEMPERATURE) == temperature + assert state.state == hvac_mode + + @pytest.mark.parametrize( ("units", "temperature"), [(UnitOfTemperature.CELSIUS, 25), (UnitOfTemperature.FAHRENHEIT, 74)], diff --git a/tests/components/group/test_binary_sensor.py b/tests/components/group/test_binary_sensor.py index 15198ac7c5b..10c1d58d3d2 100644 --- a/tests/components/group/test_binary_sensor.py +++ b/tests/components/group/test_binary_sensor.py @@ -13,7 +13,9 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -async def test_default_state(hass: HomeAssistant) -> None: +async def test_default_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test binary_sensor group default state.""" hass.states.async_set("binary_sensor.kitchen", "on") hass.states.async_set("binary_sensor.bedroom", "on") @@ -42,7 +44,6 @@ async def test_default_state(hass: HomeAssistant) -> None: "binary_sensor.bedroom", ] - entity_registry = er.async_get(hass) entry = entity_registry.async_get("binary_sensor.bedroom_group") assert entry assert entry.unique_id == "unique_identifier" @@ -145,7 +146,9 @@ async def test_state_reporting_all(hass: HomeAssistant) -> None: ) -async def test_state_reporting_any(hass: HomeAssistant) -> None: +async def test_state_reporting_any( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the state reporting in 'any' mode. The group state is unavailable if all group members are unavailable. @@ -171,7 +174,6 @@ async def test_state_reporting_any(hass: HomeAssistant) -> None: await hass.async_start() await hass.async_block_till_done() - entity_registry = er.async_get(hass) entry = entity_registry.async_get("binary_sensor.binary_sensor_group") assert entry assert entry.unique_id == "unique_identifier" diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index 1c8275c7f2d..7b83ed9eb0d 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -144,18 +144,22 @@ async def test_config_flow( ), ) async def test_config_flow_hides_members( - hass: HomeAssistant, group_type, extra_input, hide_members, hidden_by + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + group_type, + extra_input, + hide_members, + hidden_by, ) -> None: """Test the config flow hides members if requested.""" fake_uuid = "a266a680b608c32770e6c45bfe6b8411" - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( group_type, "test", "unique", suggested_object_id="one" ) assert entry.entity_id == f"{group_type}.one" assert entry.hidden_by is None - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( group_type, "test", "unique3", suggested_object_id="three" ) assert entry.entity_id == f"{group_type}.three" @@ -188,8 +192,8 @@ async def test_config_flow_hides_members( assert result["type"] == FlowResultType.CREATE_ENTRY - assert registry.async_get(f"{group_type}.one").hidden_by == hidden_by - assert registry.async_get(f"{group_type}.three").hidden_by == hidden_by + assert entity_registry.async_get(f"{group_type}.one").hidden_by == hidden_by + assert entity_registry.async_get(f"{group_type}.three").hidden_by == hidden_by def get_suggested(schema, key): @@ -402,6 +406,7 @@ async def test_all_options( ) async def test_options_flow_hides_members( hass: HomeAssistant, + entity_registry: er.EntityRegistry, group_type, extra_input, hide_members, @@ -410,8 +415,7 @@ async def test_options_flow_hides_members( ) -> None: """Test the options flow hides or unhides members if requested.""" fake_uuid = "a266a680b608c32770e6c45bfe6b8411" - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( group_type, "test", "unique1", @@ -420,7 +424,7 @@ async def test_options_flow_hides_members( ) assert entry.entity_id == f"{group_type}.one" - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( group_type, "test", "unique3", @@ -462,8 +466,8 @@ async def test_options_flow_hides_members( assert result["type"] == FlowResultType.CREATE_ENTRY - assert registry.async_get(f"{group_type}.one").hidden_by == hidden_by - assert registry.async_get(f"{group_type}.three").hidden_by == hidden_by + assert entity_registry.async_get(f"{group_type}.one").hidden_by == hidden_by + assert entity_registry.async_get(f"{group_type}.three").hidden_by == hidden_by COVER_ATTRS = [{"supported_features": 0}, {}] @@ -695,4 +699,4 @@ async def test_option_flow_sensor_preview_config_entry_removed( ) msg = await client.receive_json() assert not msg["success"] - assert msg["error"] == {"code": "unknown_error", "message": "Unknown error"} + assert msg["error"] == {"code": "home_assistant_error", "message": "Unknown error"} diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py index 4e0ddc19a31..d0eb3788763 100644 --- a/tests/components/group/test_cover.py +++ b/tests/components/group/test_cover.py @@ -249,7 +249,9 @@ async def test_state(hass: HomeAssistant, setup_comp) -> None: @pytest.mark.parametrize("config_count", [(CONFIG_ATTRIBUTES, 1)]) -async def test_attributes(hass: HomeAssistant, setup_comp) -> None: +async def test_attributes( + hass: HomeAssistant, entity_registry: er.EntityRegistry, setup_comp +) -> None: """Test handling of state attributes.""" state = hass.states.get(COVER_GROUP) assert state.state == STATE_UNAVAILABLE @@ -407,7 +409,6 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: assert ATTR_ASSUMED_STATE not in state.attributes # Test entity registry integration - entity_registry = er.async_get(hass) entry = entity_registry.async_get(COVER_GROUP) assert entry assert entry.unique_id == "unique_identifier" diff --git a/tests/components/group/test_event.py b/tests/components/group/test_event.py index 16ea11fe311..f82cc8f314b 100644 --- a/tests/components/group/test_event.py +++ b/tests/components/group/test_event.py @@ -16,7 +16,9 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -async def test_default_state(hass: HomeAssistant) -> None: +async def test_default_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test event group default state.""" await async_setup_component( hass, @@ -132,7 +134,6 @@ async def test_default_state(hass: HomeAssistant) -> None: assert state is not None assert state.state == STATE_UNAVAILABLE - entity_registry = er.async_get(hass) entry = entity_registry.async_get("event.remote_control") assert entry assert entry.unique_id == "unique_identifier" diff --git a/tests/components/group/test_fan.py b/tests/components/group/test_fan.py index 2272a29f6ed..2a1baef6798 100644 --- a/tests/components/group/test_fan.py +++ b/tests/components/group/test_fan.py @@ -112,7 +112,9 @@ async def setup_comp(hass, config_count): @pytest.mark.parametrize("config_count", [(CONFIG_ATTRIBUTES, 1)]) -async def test_state(hass: HomeAssistant, setup_comp) -> None: +async def test_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry, setup_comp +) -> None: """Test handling of state. The group state is on if at least one group member is on. @@ -201,7 +203,6 @@ async def test_state(hass: HomeAssistant, setup_comp) -> None: assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 # Test entity registry integration - entity_registry = er.async_get(hass) entry = entity_registry.async_get(FAN_GROUP) assert entry assert entry.unique_id == "unique_identifier" diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index 3ea75fbce06..5c48385c91e 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -39,7 +39,14 @@ async def test_setup_group_with_mixed_groupable_states(hass: HomeAssistant) -> N assert await async_setup_component(hass, "group", {}) await group.Group.async_create_group( - hass, "person_and_light", ["light.Bowl", "device_tracker.Paulus"] + hass, + "person_and_light", + created_by_service=False, + entity_ids=["light.Bowl", "device_tracker.Paulus"], + icon=None, + mode=None, + object_id=None, + order=None, ) await hass.async_block_till_done() @@ -54,7 +61,14 @@ async def test_setup_group_with_a_non_existing_state(hass: HomeAssistant) -> Non assert await async_setup_component(hass, "group", {}) grp = await group.Group.async_create_group( - hass, "light_and_nothing", ["light.Bowl", "non.existing"] + hass, + "light_and_nothing", + created_by_service=False, + entity_ids=["light.Bowl", "non.existing"], + icon=None, + mode=None, + object_id=None, + order=None, ) assert grp.state == STATE_ON @@ -68,7 +82,14 @@ async def test_setup_group_with_non_groupable_states(hass: HomeAssistant) -> Non assert await async_setup_component(hass, "group", {}) grp = await group.Group.async_create_group( - hass, "chromecasts", ["cast.living_room", "cast.bedroom"] + hass, + "chromecasts", + created_by_service=False, + entity_ids=["cast.living_room", "cast.bedroom"], + icon=None, + mode=None, + object_id=None, + order=None, ) assert grp.state is None @@ -76,7 +97,16 @@ async def test_setup_group_with_non_groupable_states(hass: HomeAssistant) -> Non async def test_setup_empty_group(hass: HomeAssistant) -> None: """Try to set up an empty group.""" - grp = await group.Group.async_create_group(hass, "nothing", []) + grp = await group.Group.async_create_group( + hass, + "nothing", + created_by_service=False, + entity_ids=[], + icon=None, + mode=None, + object_id=None, + order=None, + ) assert grp.state is None @@ -89,7 +119,14 @@ async def test_monitor_group(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=None, + object_id=None, + order=None, ) # Test if group setup in our init mode is ok @@ -108,7 +145,14 @@ async def test_group_turns_off_if_all_off(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=None, + object_id=None, + order=None, ) await hass.async_block_till_done() @@ -127,7 +171,14 @@ async def test_group_turns_on_if_all_are_off_and_one_turns_on( assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=None, + object_id=None, + order=None, ) # Turn one on @@ -148,7 +199,14 @@ async def test_allgroup_stays_off_if_all_are_off_and_one_turns_on( assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False, mode=True + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=True, + object_id=None, + order=None, ) # Turn one on @@ -167,7 +225,14 @@ async def test_allgroup_turn_on_if_last_turns_on(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False, mode=True + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=True, + object_id=None, + order=None, ) # Turn one on @@ -186,7 +251,14 @@ async def test_expand_entity_ids(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=None, + object_id=None, + order=None, ) assert sorted(["light.ceiling", "light.bowl"]) == sorted( @@ -204,7 +276,14 @@ async def test_expand_entity_ids_does_not_return_duplicates( assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=None, + object_id=None, + order=None, ) assert ["light.bowl", "light.ceiling"] == sorted( @@ -226,8 +305,12 @@ async def test_expand_entity_ids_recursive(hass: HomeAssistant) -> None: test_group = await group.Group.async_create_group( hass, "init_group", - ["light.Bowl", "light.Ceiling", "group.init_group"], - False, + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling", "group.init_group"], + icon=None, + mode=None, + object_id=None, + order=None, ) assert sorted(["light.ceiling", "light.bowl"]) == sorted( @@ -248,7 +331,14 @@ async def test_get_entity_ids(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=None, + object_id=None, + order=None, ) assert ["light.bowl", "light.ceiling"] == sorted( @@ -263,7 +353,14 @@ async def test_get_entity_ids_with_domain_filter(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) mixed_group = await group.Group.async_create_group( - hass, "mixed_group", ["light.Bowl", "switch.AC"], False + hass, + "mixed_group", + created_by_service=True, + entity_ids=["light.Bowl", "switch.AC"], + icon=None, + mode=None, + object_id=None, + order=None, ) assert ["switch.ac"] == group.get_entity_ids( @@ -293,7 +390,14 @@ async def test_group_being_init_before_first_tracked_state_is_set_to_on( assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "test group", ["light.not_there_1"] + hass, + "test group", + created_by_service=False, + entity_ids=["light.not_there_1"], + icon=None, + mode=None, + object_id=None, + order=None, ) hass.states.async_set("light.not_there_1", STATE_ON) @@ -314,7 +418,14 @@ async def test_group_being_init_before_first_tracked_state_is_set_to_off( """ assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "test group", ["light.not_there_1"] + hass, + "test group", + created_by_service=False, + entity_ids=["light.not_there_1"], + icon=None, + mode=None, + object_id=None, + order=None, ) hass.states.async_set("light.not_there_1", STATE_OFF) @@ -330,8 +441,26 @@ async def test_groups_get_unique_names(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) - grp1 = await group.Group.async_create_group(hass, "Je suis Charlie") - grp2 = await group.Group.async_create_group(hass, "Je suis Charlie") + grp1 = await group.Group.async_create_group( + hass, + "Je suis Charlie", + created_by_service=False, + entity_ids=None, + icon=None, + mode=None, + object_id=None, + order=None, + ) + grp2 = await group.Group.async_create_group( + hass, + "Je suis Charlie", + created_by_service=False, + entity_ids=None, + icon=None, + mode=None, + object_id=None, + order=None, + ) assert grp1.entity_id != grp2.entity_id @@ -342,13 +471,34 @@ async def test_expand_entity_ids_expands_nested_groups(hass: HomeAssistant) -> N assert await async_setup_component(hass, "group", {}) await group.Group.async_create_group( - hass, "light", ["light.test_1", "light.test_2"] + hass, + "light", + created_by_service=False, + entity_ids=["light.test_1", "light.test_2"], + icon=None, + mode=None, + object_id=None, + order=None, ) await group.Group.async_create_group( - hass, "switch", ["switch.test_1", "switch.test_2"] + hass, + "switch", + created_by_service=False, + entity_ids=["switch.test_1", "switch.test_2"], + icon=None, + mode=None, + object_id=None, + order=None, ) await group.Group.async_create_group( - hass, "group_of_groups", ["group.light", "group.switch"] + hass, + "group_of_groups", + created_by_service=False, + entity_ids=["group.light", "group.switch"], + icon=None, + mode=None, + object_id=None, + order=None, ) assert [ @@ -367,7 +517,14 @@ async def test_set_assumed_state_based_on_tracked(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling", "sensor.no_exist"] + hass, + "init_group", + created_by_service=False, + entity_ids=["light.Bowl", "light.Ceiling", "sensor.no_exist"], + icon=None, + mode=None, + object_id=None, + order=None, ) state = hass.states.get(test_group.entity_id) @@ -398,7 +555,14 @@ async def test_group_updated_after_device_tracker_zone_change( assert await async_setup_component(hass, "device_tracker", {}) await group.Group.async_create_group( - hass, "peeps", ["device_tracker.Adam", "device_tracker.Eve"] + hass, + "peeps", + created_by_service=False, + entity_ids=["device_tracker.Adam", "device_tracker.Eve"], + icon=None, + mode=None, + object_id=None, + order=None, ) hass.states.async_set("device_tracker.Adam", "cool_state_not_home") @@ -417,7 +581,14 @@ async def test_is_on(hass: HomeAssistant) -> None: await hass.async_block_till_done() test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=None, + object_id=None, + order=None, ) await hass.async_block_till_done() @@ -446,7 +617,14 @@ async def test_reloading_groups(hass: HomeAssistant) -> None: await hass.async_block_till_done() await group.Group.async_create_group( - hass, "all tests", ["test.one", "test.two"], user_defined=False + hass, + "all tests", + created_by_service=True, + entity_ids=["test.one", "test.two"], + icon=None, + mode=None, + object_id=None, + order=None, ) await hass.async_block_till_done() @@ -523,14 +701,24 @@ async def test_setup(hass: HomeAssistant) -> None: await hass.async_block_till_done() test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=None, + object_id=None, + order=None, ) await group.Group.async_create_group( hass, "created_group", - ["light.Bowl", f"{test_group.entity_id}"], - True, - "mdi:work", + created_by_service=False, + entity_ids=["light.Bowl", f"{test_group.entity_id}"], + icon="mdi:work", + mode=None, + object_id=None, + order=None, ) await hass.async_block_till_done() @@ -1453,13 +1641,12 @@ async def test_plant_group(hass: HomeAssistant) -> None: ) async def test_setup_and_remove_config_entry( hass: HomeAssistant, + entity_registry: er.EntityRegistry, group_type: str, member_state: str, extra_options: dict[str, Any], ) -> None: """Test removing a config entry.""" - registry = er.async_get(hass) - members1 = [f"{group_type}.one", f"{group_type}.two"] for member in members1: @@ -1484,7 +1671,7 @@ async def test_setup_and_remove_config_entry( # Check the state and entity registry entry are present state = hass.states.get(f"{group_type}.bed_room") assert state.attributes["entity_id"] == members1 - assert registry.async_get(f"{group_type}.bed_room") is not None + assert entity_registry.async_get(f"{group_type}.bed_room") is not None # Remove the config entry assert await hass.config_entries.async_remove(group_config_entry.entry_id) @@ -1492,7 +1679,7 @@ async def test_setup_and_remove_config_entry( # Check the state and entity registry entry are removed assert hass.states.get(f"{group_type}.bed_room") is None - assert registry.async_get(f"{group_type}.bed_room") is None + assert entity_registry.async_get(f"{group_type}.bed_room") is None @pytest.mark.parametrize( @@ -1518,6 +1705,7 @@ async def test_setup_and_remove_config_entry( ) async def test_unhide_members_on_remove( hass: HomeAssistant, + entity_registry: er.EntityRegistry, group_type: str, extra_options: dict[str, Any], hide_members: bool, @@ -1525,10 +1713,7 @@ async def test_unhide_members_on_remove( hidden_by: str, ) -> None: """Test removing a config entry.""" - registry = er.async_get(hass) - - registry = er.async_get(hass) - entry1 = registry.async_get_or_create( + entry1 = entity_registry.async_get_or_create( group_type, "test", "unique1", @@ -1537,7 +1722,7 @@ async def test_unhide_members_on_remove( ) assert entry1.entity_id == f"{group_type}.one" - entry3 = registry.async_get_or_create( + entry3 = entity_registry.async_get_or_create( group_type, "test", "unique3", @@ -1546,7 +1731,7 @@ async def test_unhide_members_on_remove( ) assert entry3.entity_id == f"{group_type}.three" - entry4 = registry.async_get_or_create( + entry4 = entity_registry.async_get_or_create( group_type, "test", "unique4", @@ -1578,12 +1763,12 @@ async def test_unhide_members_on_remove( # Remove one entity registry entry, to make sure this does not trip up config entry # removal - registry.async_remove(entry4.entity_id) + entity_registry.async_remove(entry4.entity_id) # Remove the config entry assert await hass.config_entries.async_remove(group_config_entry.entry_id) await hass.async_block_till_done() # Check the group members are unhidden - assert registry.async_get(f"{group_type}.one").hidden_by == hidden_by - assert registry.async_get(f"{group_type}.three").hidden_by == hidden_by + assert entity_registry.async_get(f"{group_type}.one").hidden_by == hidden_by + assert entity_registry.async_get(f"{group_type}.three").hidden_by == hidden_by diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index 062cf161bb9..3051ec502a0 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -49,7 +49,9 @@ from homeassistant.setup import async_setup_component from tests.common import async_capture_events, get_fixture_path -async def test_default_state(hass: HomeAssistant) -> None: +async def test_default_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test light group default state.""" hass.states.async_set("light.kitchen", "on") await async_setup_component( @@ -80,7 +82,6 @@ async def test_default_state(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_EFFECT_LIST) is None assert state.attributes.get(ATTR_EFFECT) is None - entity_registry = er.async_get(hass) entry = entity_registry.async_get("light.bedroom_group") assert entry assert entry.unique_id == "unique_identifier" diff --git a/tests/components/group/test_lock.py b/tests/components/group/test_lock.py index b8a1838bca5..c8102b79ff9 100644 --- a/tests/components/group/test_lock.py +++ b/tests/components/group/test_lock.py @@ -31,7 +31,9 @@ from homeassistant.setup import async_setup_component from tests.common import get_fixture_path -async def test_default_state(hass: HomeAssistant) -> None: +async def test_default_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test lock group default state.""" hass.states.async_set("lock.front", "locked") await async_setup_component( @@ -55,7 +57,6 @@ async def test_default_state(hass: HomeAssistant) -> None: assert state.state == STATE_LOCKED assert state.attributes.get(ATTR_ENTITY_ID) == ["lock.front", "lock.back"] - entity_registry = er.async_get(hass) entry = entity_registry.async_get("lock.door_group") assert entry assert entry.unique_id == "unique_identifier" diff --git a/tests/components/group/test_media_player.py b/tests/components/group/test_media_player.py index e1f269a947d..9f36693d9ef 100644 --- a/tests/components/group/test_media_player.py +++ b/tests/components/group/test_media_player.py @@ -58,7 +58,9 @@ def media_player_media_seek_fixture(): yield seek -async def test_default_state(hass: HomeAssistant) -> None: +async def test_default_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test media group default state.""" hass.states.async_set("media_player.player_1", "on") await async_setup_component( @@ -86,7 +88,6 @@ async def test_default_state(hass: HomeAssistant) -> None: "media_player.player_2", ] - entity_registry = er.async_get(hass) entry = entity_registry.async_get("media_player.media_group") assert entry assert entry.unique_id == "unique_identifier" diff --git a/tests/components/group/test_sensor.py b/tests/components/group/test_sensor.py index 39c9b788d56..71a53042938 100644 --- a/tests/components/group/test_sensor.py +++ b/tests/components/group/test_sensor.py @@ -64,6 +64,7 @@ PRODUCT_VALUE = prod(VALUES) ) async def test_sensors( hass: HomeAssistant, + entity_registry: er.EntityRegistry, sensor_type: str, result: str, attributes: dict[str, Any], @@ -107,8 +108,7 @@ async def test_sensors( assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "L" - entity_reg = er.async_get(hass) - entity = entity_reg.async_get(f"sensor.sensor_group_{sensor_type}") + entity = entity_registry.async_get(f"sensor.sensor_group_{sensor_type}") assert entity.unique_id == "very_unique_id" diff --git a/tests/components/group/test_switch.py b/tests/components/group/test_switch.py index bc9a05f4754..86f6eb43ed9 100644 --- a/tests/components/group/test_switch.py +++ b/tests/components/group/test_switch.py @@ -24,7 +24,9 @@ from homeassistant.setup import async_setup_component from tests.common import get_fixture_path -async def test_default_state(hass: HomeAssistant) -> None: +async def test_default_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test switch group default state.""" hass.states.async_set("switch.tv", "on") await async_setup_component( @@ -49,7 +51,6 @@ async def test_default_state(hass: HomeAssistant) -> None: assert state.state == STATE_ON assert state.attributes.get(ATTR_ENTITY_ID) == ["switch.tv", "switch.soundbar"] - entity_registry = er.async_get(hass) entry = entity_registry.async_get("switch.multimedia_group") assert entry assert entry.unique_id == "unique_identifier" diff --git a/tests/components/guardian/conftest.py b/tests/components/guardian/conftest.py index acf59aeea86..f2cde0a553d 100644 --- a/tests/components/guardian/conftest.py +++ b/tests/components/guardian/conftest.py @@ -131,9 +131,10 @@ async def setup_guardian_fixture( "aioguardian.commands.wifi.WiFiCommands.status", return_value=data_wifi_status, ), patch( - "aioguardian.client.Client.disconnect" + "aioguardian.client.Client.disconnect", ), patch( - "homeassistant.components.guardian.PLATFORMS", [] + "homeassistant.components.guardian.PLATFORMS", + [], ): assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() diff --git a/tests/components/harmony/test_switch.py b/tests/components/harmony/test_switch.py index 58cbd3eac56..59e5a7c7fc8 100644 --- a/tests/components/harmony/test_switch.py +++ b/tests/components/harmony/test_switch.py @@ -1,7 +1,10 @@ """Test the Logitech Harmony Hub activity switches.""" from datetime import timedelta +from homeassistant.components import automation, script +from homeassistant.components.automation import automations_with_entity from homeassistant.components.harmony.const import DOMAIN +from homeassistant.components.script import scripts_with_entity from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -17,6 +20,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +import homeassistant.helpers.issue_registry as ir +from homeassistant.setup import async_setup_component from homeassistant.util import utcnow from .const import ENTITY_PLAY_MUSIC, ENTITY_REMOTE, ENTITY_WATCH_TV, HUB_NAME @@ -133,3 +138,62 @@ async def _toggle_switch_and_wait(hass, service_name, entity): blocking=True, ) await hass.async_block_till_done() + + +async def test_create_issue( + harmony_client, + mock_hc, + hass: HomeAssistant, + mock_write_config, + entity_registry_enabled_by_default: None, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "alias": "test", + "trigger": {"platform": "state", "entity_id": ENTITY_WATCH_TV}, + "action": {"service": "switch.turn_on", "entity_id": ENTITY_WATCH_TV}, + } + }, + ) + assert await async_setup_component( + hass, + script.DOMAIN, + { + script.DOMAIN: { + "test": { + "sequence": [ + { + "service": "switch.turn_on", + "data": {"entity_id": ENTITY_WATCH_TV}, + }, + ], + } + } + }, + ) + + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "192.0.2.0", CONF_NAME: HUB_NAME} + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert automations_with_entity(hass, ENTITY_WATCH_TV)[0] == "automation.test" + assert scripts_with_entity(hass, ENTITY_WATCH_TV)[0] == "script.test" + issue_registry: ir.IssueRegistry = ir.async_get(hass) + + assert issue_registry.async_get_issue(DOMAIN, "deprecated_switches") + assert issue_registry.async_get_issue( + DOMAIN, "deprecated_switches_switch.guest_room_watch_tv_automation.test" + ) + assert issue_registry.async_get_issue( + DOMAIN, "deprecated_switches_switch.guest_room_watch_tv_script.test" + ) + + assert len(issue_registry.issues) == 3 diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 22051808ccc..0cce33f6dfd 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -54,9 +54,9 @@ def hassio_stubs(hassio_env, hass, hass_client, aioclient_mock): "homeassistant.components.hassio.HassIO.get_ingress_panels", return_value={"panels": []}, ), patch( - "homeassistant.components.hassio.issues.SupervisorIssues.setup" + "homeassistant.components.hassio.issues.SupervisorIssues.setup", ), patch( - "homeassistant.components.hassio.HassIO.refresh_updates" + "homeassistant.components.hassio.HassIO.refresh_updates", ): hass.state = CoreState.starting hass.loop.run_until_complete(async_setup_component(hass, "hassio", {})) diff --git a/tests/components/hassio/test_discovery.py b/tests/components/hassio/test_discovery.py index 5c4717fd561..0923967a480 100644 --- a/tests/components/hassio/test_discovery.py +++ b/tests/components/hassio/test_discovery.py @@ -12,12 +12,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_S from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import ( - MockModule, - mock_config_flow, - mock_entity_platform, - mock_integration, -) +from tests.common import MockModule, mock_config_flow, mock_integration, mock_platform from tests.test_util.aiohttp import AiohttpClientMocker @@ -25,7 +20,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def mock_mqtt_fixture(hass): """Mock the MQTT integration's config flow.""" mock_integration(hass, MockModule(MQTT_DOMAIN)) - mock_entity_platform(hass, f"config_flow.{MQTT_DOMAIN}", None) + mock_platform(hass, f"{MQTT_DOMAIN}.config_flow", None) class MqttFlow(config_entries.ConfigFlow): """Test flow.""" diff --git a/tests/components/hassio/test_ingress.py b/tests/components/hassio/test_ingress.py index 3eda10b1514..c8255ac0496 100644 --- a/tests/components/hassio/test_ingress.py +++ b/tests/components/hassio/test_ingress.py @@ -427,6 +427,30 @@ async def test_ingress_request_not_compressed( assert "Content-Encoding" not in resp.headers +async def test_ingress_request_with_charset_in_content_type( + hassio_noauth_client, aioclient_mock: AiohttpClientMocker +) -> None: + """Test ingress passes content type.""" + body = b"this_is_long_enough_to_be_compressed" * 100 + aioclient_mock.get( + "http://127.0.0.1/ingress/core/x.any", + data=body, + headers={ + "Content-Length": len(body), + "Content-Type": "text/html; charset=utf-8", + }, + ) + + resp = await hassio_noauth_client.get( + "/api/hassio_ingress/core/x.any", + headers={"X-Test-Header": "beer", "Accept-Encoding": "gzip, deflate"}, + ) + + # Check we got right response + assert resp.status == HTTPStatus.OK + assert resp.headers["Content-Type"] == "text/html" + + @pytest.mark.parametrize( "content_type", [ diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 1784ba83446..70d96a0d5cb 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -249,6 +249,8 @@ async def test_updates_from_players_changed( async def test_updates_from_players_changed_new_ids( hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, config_entry, config, controller, @@ -257,8 +259,6 @@ async def test_updates_from_players_changed_new_ids( ) -> None: """Test player updates from changes to available players.""" await setup_platform(hass, config_entry, config) - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) player = controller.players[1] event = asyncio.Event() diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index bb4b5b275d2..c421a1b8c5c 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -1664,7 +1664,9 @@ async def test_history_stats_handles_floored_timestamps( assert last_times == (start_time, start_time + timedelta(hours=2)) -async def test_unique_id(recorder_mock: Recorder, hass: HomeAssistant) -> None: +async def test_unique_id( + recorder_mock: Recorder, hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test unique_id property.""" config = { @@ -1682,5 +1684,7 @@ async def test_unique_id(recorder_mock: Recorder, hass: HomeAssistant) -> None: assert await async_setup_component(hass, "sensor", config) await hass.async_block_till_done() - registry = er.async_get(hass) - assert registry.async_get("sensor.test").unique_id == "some_history_stats_unique_id" + assert ( + entity_registry.async_get("sensor.test").unique_id + == "some_history_stats_unique_id" + ) diff --git a/tests/components/homeassistant/test_scene.py b/tests/components/homeassistant/test_scene.py index 085ed4f0641..d754c67ad49 100644 --- a/tests/components/homeassistant/test_scene.py +++ b/tests/components/homeassistant/test_scene.py @@ -8,6 +8,7 @@ from homeassistant.components.homeassistant import scene as ha_scene from homeassistant.components.homeassistant.scene import EVENT_SCENE_RELOADED from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component from tests.common import async_capture_events, async_mock_service @@ -164,6 +165,65 @@ async def test_create_service( assert scene.attributes.get("entity_id") == ["light.kitchen"] +async def test_delete_service( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the delete service.""" + assert await async_setup_component( + hass, + "scene", + {"scene": {"name": "hallo_2", "entities": {"light.kitchen": "on"}}}, + ) + + await hass.services.async_call( + "scene", + "create", + { + "scene_id": "hallo", + "entities": {"light.bed_light": {"state": "on", "brightness": 50}}, + }, + blocking=True, + ) + await hass.async_block_till_done() + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + "scene", + "delete", + { + "entity_id": "scene.hallo_3", + }, + blocking=True, + ) + await hass.async_block_till_done() + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + "scene", + "delete", + { + "entity_id": "scene.hallo_2", + }, + blocking=True, + ) + await hass.async_block_till_done() + assert hass.states.get("scene.hallo_2") is not None + + assert hass.states.get("scene.hallo") is not None + + await hass.services.async_call( + "scene", + "delete", + { + "entity_id": "scene.hallo", + }, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get("state.hallo") is None + + async def test_snapshot_service( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/components/homeassistant/triggers/test_numeric_state.py b/tests/components/homeassistant/triggers/test_numeric_state.py index b5bd748a5dc..92c8aac3eba 100644 --- a/tests/components/homeassistant/triggers/test_numeric_state.py +++ b/tests/components/homeassistant/triggers/test_numeric_state.py @@ -141,11 +141,10 @@ async def test_if_fires_on_entity_change_below( "below", (10, "input_number.value_10", "number.value_10", "sensor.value_10") ) async def test_if_fires_on_entity_change_below_uuid( - hass: HomeAssistant, calls, below + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls, below ) -> None: """Test the firing with changed entity specified by registry entry id.""" - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( "test", "hue", "1234", suggested_object_id="entity" ) assert entry.entity_id == "test.entity" diff --git a/tests/components/homeassistant/triggers/test_state.py b/tests/components/homeassistant/triggers/test_state.py index 9870beedafc..a8f001ff5e0 100644 --- a/tests/components/homeassistant/triggers/test_state.py +++ b/tests/components/homeassistant/triggers/test_state.py @@ -89,12 +89,13 @@ async def test_if_fires_on_entity_change(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_fires_on_entity_change_uuid(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_entity_change_uuid( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls +) -> None: """Test for firing on entity change.""" context = Context() - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( "test", "hue", "1234", suggested_object_id="beer" ) diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index fbc77cdee9e..f58d561bfb3 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -293,7 +293,14 @@ async def test_option_flow_install_multi_pan_addon_zha( config_entry.add_to_hass(hass) zha_config_entry = MockConfigEntry( - data={"device": {"path": "/dev/ttyTEST123"}, "radio_type": "ezsp"}, + data={ + "device": { + "path": "/dev/ttyTEST123", + "baudrate": 115200, + "flow_control": None, + }, + "radio_type": "ezsp", + }, domain=ZHA_DOMAIN, options={}, title="Test", @@ -348,8 +355,8 @@ async def test_option_flow_install_multi_pan_addon_zha( assert zha_config_entry.data == { "device": { "path": "socket://core-silabs-multiprotocol:9999", - "baudrate": 57600, # ZHA default - "flow_control": "software", # ZHA default + "baudrate": 115200, + "flow_control": None, }, "radio_type": "ezsp", } diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 4d43d29463a..65636b27a16 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -337,8 +337,8 @@ async def test_option_flow_install_multi_pan_addon_zha( assert zha_config_entry.data == { "device": { "path": "socket://core-silabs-multiprotocol:9999", - "baudrate": 57600, # ZHA default - "flow_control": "software", # ZHA default + "baudrate": 115200, + "flow_control": None, }, "radio_type": "ezsp", } diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index e00603dc8f7..11961c09a2d 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -147,7 +147,7 @@ async def test_setup_zha( assert config_entry.data == { "device": { "baudrate": 115200, - "flow_control": "software", + "flow_control": None, "path": CONFIG_ENTRY_DATA["device"], }, "radio_type": "ezsp", @@ -200,8 +200,8 @@ async def test_setup_zha_multipan( config_entry = hass.config_entries.async_entries("zha")[0] assert config_entry.data == { "device": { - "baudrate": 57600, # ZHA default - "flow_control": "software", # ZHA default + "baudrate": 115200, + "flow_control": None, "path": "socket://core-silabs-multiprotocol:9999", }, "radio_type": "ezsp", @@ -255,7 +255,7 @@ async def test_setup_zha_multipan_other_device( assert config_entry.data == { "device": { "baudrate": 115200, - "flow_control": "software", + "flow_control": None, "path": CONFIG_ENTRY_DATA["device"], }, "radio_type": "ezsp", diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 58d47c41987..242b316de66 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -249,8 +249,8 @@ async def test_option_flow_install_multi_pan_addon_zha( assert zha_config_entry.data == { "device": { "path": "socket://core-silabs-multiprotocol:9999", - "baudrate": 57600, # ZHA default - "flow_control": "software", # ZHA default + "baudrate": 115200, + "flow_control": None, }, "radio_type": "ezsp", } diff --git a/tests/components/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py index addc519c865..f8cdcd8a13b 100644 --- a/tests/components/homeassistant_yellow/test_init.py +++ b/tests/components/homeassistant_yellow/test_init.py @@ -145,8 +145,8 @@ async def test_setup_zha_multipan( config_entry = hass.config_entries.async_entries("zha")[0] assert config_entry.data == { "device": { - "baudrate": 57600, # ZHA default - "flow_control": "software", # ZHA default + "baudrate": 115200, + "flow_control": None, "path": "socket://core-silabs-multiprotocol:9999", }, "radio_type": "ezsp", diff --git a/tests/components/homekit/conftest.py b/tests/components/homekit/conftest.py index fe151c902cb..8c6d4328065 100644 --- a/tests/components/homekit/conftest.py +++ b/tests/components/homekit/conftest.py @@ -31,7 +31,7 @@ def run_driver(hass, event_loop, iid_storage): ), patch("pyhap.accessory_driver.HAPServer"), patch( "pyhap.accessory_driver.AccessoryDriver.publish" ), patch( - "pyhap.accessory_driver.AccessoryDriver.persist" + "pyhap.accessory_driver.AccessoryDriver.persist", ): yield HomeDriver( hass, @@ -53,9 +53,9 @@ def hk_driver(hass, event_loop, iid_storage): ), patch("pyhap.accessory_driver.HAPServer.async_stop"), patch( "pyhap.accessory_driver.HAPServer.async_start" ), patch( - "pyhap.accessory_driver.AccessoryDriver.publish" + "pyhap.accessory_driver.AccessoryDriver.publish", ), patch( - "pyhap.accessory_driver.AccessoryDriver.persist" + "pyhap.accessory_driver.AccessoryDriver.persist", ): yield HomeDriver( hass, @@ -77,13 +77,13 @@ def mock_hap(hass, event_loop, iid_storage, mock_zeroconf): ), patch("pyhap.accessory_driver.HAPServer.async_stop"), patch( "pyhap.accessory_driver.HAPServer.async_start" ), patch( - "pyhap.accessory_driver.AccessoryDriver.publish" + "pyhap.accessory_driver.AccessoryDriver.publish", ), patch( - "pyhap.accessory_driver.AccessoryDriver.async_start" + "pyhap.accessory_driver.AccessoryDriver.async_start", ), patch( - "pyhap.accessory_driver.AccessoryDriver.async_stop" + "pyhap.accessory_driver.AccessoryDriver.async_stop", ), patch( - "pyhap.accessory_driver.AccessoryDriver.persist" + "pyhap.accessory_driver.AccessoryDriver.persist", ): yield HomeDriver( hass, diff --git a/tests/components/homekit/test_aidmanager.py b/tests/components/homekit/test_aidmanager.py index 447cdc99a57..64c5cd9cc74 100644 --- a/tests/components/homekit/test_aidmanager.py +++ b/tests/components/homekit/test_aidmanager.py @@ -622,9 +622,9 @@ async def test_aid_generation_no_unique_ids_handles_collision( async def test_handle_unique_id_change( hass: HomeAssistant, + entity_registry: er.EntityRegistry, ) -> None: """Test handling unique id changes.""" - entity_registry = er.async_get(hass) light = entity_registry.async_get_or_create("light", "demo", "old_unique") config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 158efa477d4..1d42325d54c 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -1202,9 +1202,7 @@ async def test_homekit_reset_accessories_not_supported( "pyhap.accessory_driver.AccessoryDriver.async_update_advertisement" ) as hk_driver_async_update_advertisement, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" - ), patch.object( - homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 - ): + ), patch.object(homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0): await async_init_entry(hass, entry) acc_mock = MagicMock() @@ -1247,9 +1245,7 @@ async def test_homekit_reset_accessories_state_missing( "pyhap.accessory_driver.AccessoryDriver.config_changed" ) as hk_driver_config_changed, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" - ), patch.object( - homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 - ): + ), patch.object(homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0): await async_init_entry(hass, entry) acc_mock = MagicMock() @@ -1291,9 +1287,7 @@ async def test_homekit_reset_accessories_not_bridged( "pyhap.accessory_driver.AccessoryDriver.async_update_advertisement" ) as hk_driver_async_update_advertisement, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" - ), patch.object( - homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 - ): + ), patch.object(homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0): await async_init_entry(hass, entry) assert hk_driver_async_update_advertisement.call_count == 0 @@ -1338,7 +1332,7 @@ async def test_homekit_reset_single_accessory( ) as hk_driver_async_update_advertisement, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" ), patch( - f"{PATH_HOMEKIT}.accessories.HomeAccessory.run" + f"{PATH_HOMEKIT}.accessories.HomeAccessory.run", ) as mock_run: await async_init_entry(hass, entry) homekit.status = STATUS_RUNNING @@ -2071,9 +2065,9 @@ async def test_reload(hass: HomeAssistant, mock_async_zeroconf: None) -> None: ) as mock_homekit2, patch.object(homekit.bridge, "add_accessory"), patch( f"{PATH_HOMEKIT}.async_show_setup_message" ), patch( - f"{PATH_HOMEKIT}.get_accessory" + f"{PATH_HOMEKIT}.get_accessory", ), patch( - "pyhap.accessory_driver.AccessoryDriver.async_start" + "pyhap.accessory_driver.AccessoryDriver.async_start", ), patch( "homeassistant.components.network.async_get_source_ip", return_value="1.2.3.4" ): diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index b8841289611..a44db05a37b 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -607,20 +607,18 @@ async def test_windowcovering_open_close_with_position_and_stop( async def test_windowcovering_basic_restore( - hass: HomeAssistant, hk_driver, events + hass: HomeAssistant, entity_registry: er.EntityRegistry, hk_driver, events ) -> None: """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( "cover", "generic", "1234", suggested_object_id="simple", ) - registry.async_get_or_create( + entity_registry.async_get_or_create( "cover", "generic", "9012", @@ -646,19 +644,19 @@ async def test_windowcovering_basic_restore( assert acc.char_position_state is not None -async def test_windowcovering_restore(hass: HomeAssistant, hk_driver, events) -> None: - """Test setting up an entity from state in the event registry.""" +async def test_windowcovering_restore( + hass: HomeAssistant, entity_registry: er.EntityRegistry, hk_driver, events +) -> None: + """Test setting up an entity from state in the event entity_registry.""" hass.state = CoreState.not_running - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( "cover", "generic", "1234", suggested_object_id="simple", ) - registry.async_get_or_create( + entity_registry.async_get_or_create( "cover", "generic", "9012", diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py index df54cce1b3f..118e67a43b1 100644 --- a/tests/components/homekit/test_type_fans.py +++ b/tests/components/homekit/test_type_fans.py @@ -553,19 +553,19 @@ async def test_fan_set_all_one_shot(hass: HomeAssistant, hk_driver, events) -> N assert len(call_set_direction) == 2 -async def test_fan_restore(hass: HomeAssistant, hk_driver, events) -> None: +async def test_fan_restore( + hass: HomeAssistant, entity_registry: er.EntityRegistry, hk_driver, events +) -> None: """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( "fan", "generic", "1234", suggested_object_id="simple", ) - registry.async_get_or_create( + entity_registry.async_get_or_create( "fan", "generic", "9012", diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index 6fae8337aae..7568e7a4844 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -576,14 +576,16 @@ async def test_light_rgb_color( assert events[-1].data[ATTR_VALUE] == "set color at (145, 75)" -async def test_light_restore(hass: HomeAssistant, hk_driver, events) -> None: +async def test_light_restore( + hass: HomeAssistant, entity_registry: er.EntityRegistry, hk_driver, events +) -> None: """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running - registry = er.async_get(hass) - - registry.async_get_or_create("light", "hue", "1234", suggested_object_id="simple") - registry.async_get_or_create( + entity_registry.async_get_or_create( + "light", "hue", "1234", suggested_object_id="simple" + ) + entity_registry.async_get_or_create( "light", "hue", "9012", diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index 104b9dd61ce..1954d6bf8ca 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -428,20 +428,20 @@ async def test_media_player_television_supports_source_select_no_sources( assert acc.support_select_source is False -async def test_tv_restore(hass: HomeAssistant, hk_driver, events) -> None: +async def test_tv_restore( + hass: HomeAssistant, entity_registry: er.EntityRegistry, hk_driver, events +) -> None: """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( "media_player", "generic", "1234", suggested_object_id="simple", original_device_class=MediaPlayerDeviceClass.TV, ) - registry.async_get_or_create( + entity_registry.async_get_or_create( "media_player", "generic", "9012", diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index d2f0d87c507..23e53eef94d 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -541,20 +541,20 @@ async def test_binary_device_classes(hass: HomeAssistant, hk_driver) -> None: assert acc.char_detected.display_name == char -async def test_sensor_restore(hass: HomeAssistant, hk_driver, events) -> None: +async def test_sensor_restore( + hass: HomeAssistant, entity_registry: er.EntityRegistry, hk_driver, events +) -> None: """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( "sensor", "generic", "1234", suggested_object_id="temperature", original_device_class="temperature", ) - registry.async_get_or_create( + entity_registry.async_get_or_create( "sensor", "generic", "12345", diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index 1c3fb0914f3..5bfbe0b1627 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -964,16 +964,16 @@ async def test_thermostat_temperature_step_whole( assert acc.char_target_temp.properties[PROP_MIN_STEP] == 0.1 -async def test_thermostat_restore(hass: HomeAssistant, hk_driver, events) -> None: +async def test_thermostat_restore( + hass: HomeAssistant, entity_registry: er.EntityRegistry, hk_driver, events +) -> None: """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( "climate", "generic", "1234", suggested_object_id="simple" ) - registry.async_get_or_create( + entity_registry.async_get_or_create( "climate", "generic", "9012", @@ -1794,16 +1794,16 @@ async def test_water_heater_get_temperature_range( assert acc.get_temperature_range(state) == (15.5, 21.0) -async def test_water_heater_restore(hass: HomeAssistant, hk_driver, events) -> None: +async def test_water_heater_restore( + hass: HomeAssistant, entity_registry: er.EntityRegistry, hk_driver, events +) -> None: """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( "water_heater", "generic", "1234", suggested_object_id="simple" ) - registry.async_get_or_create( + entity_registry.async_get_or_create( "water_heater", "generic", "9012", diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index 7b721e76bba..62051bbf244 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -142,7 +142,9 @@ async def test_ecobee3_setup(hass: HomeAssistant) -> None: async def test_ecobee3_setup_from_cache( - hass: HomeAssistant, hass_storage: dict[str, Any] + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_storage: dict[str, Any], ) -> None: """Test that Ecbobee can be correctly setup from its cached entity map.""" accessories = await setup_accessories_from_file(hass, "ecobee3.json") @@ -163,8 +165,6 @@ async def test_ecobee3_setup_from_cache( await setup_test_accessories(hass, accessories) - entity_registry = er.async_get(hass) - climate = entity_registry.async_get("climate.homew") assert climate.unique_id == "00:00:00:00:00:00_1_16" @@ -178,12 +178,12 @@ async def test_ecobee3_setup_from_cache( assert occ3.unique_id == "00:00:00:00:00:00_4_56" -async def test_ecobee3_setup_connection_failure(hass: HomeAssistant) -> None: +async def test_ecobee3_setup_connection_failure( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that Ecbobee can be correctly setup from its cached entity map.""" accessories = await setup_accessories_from_file(hass, "ecobee3.json") - entity_registry = er.async_get(hass) - # Test that the connection fails during initial setup. # No entities should be created. with mock.patch.object(FakePairing, "async_populate_accessories_state") as laac: @@ -218,9 +218,10 @@ async def test_ecobee3_setup_connection_failure(hass: HomeAssistant) -> None: assert occ3.unique_id == "00:00:00:00:00:00_4_56" -async def test_ecobee3_add_sensors_at_runtime(hass: HomeAssistant) -> None: +async def test_ecobee3_add_sensors_at_runtime( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that new sensors are automatically added.""" - entity_registry = er.async_get(hass) # Set up a base Ecobee 3 with no additional sensors. # There shouldn't be any entities but climate visible. @@ -254,9 +255,10 @@ async def test_ecobee3_add_sensors_at_runtime(hass: HomeAssistant) -> None: assert occ3.unique_id == "00:00:00:00:00:00_4_56" -async def test_ecobee3_remove_sensors_at_runtime(hass: HomeAssistant) -> None: +async def test_ecobee3_remove_sensors_at_runtime( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that sensors are automatically removed.""" - entity_registry = er.async_get(hass) # Set up a base Ecobee 3 with additional sensors. accessories = await setup_accessories_from_file(hass, "ecobee3.json") @@ -307,10 +309,9 @@ async def test_ecobee3_remove_sensors_at_runtime(hass: HomeAssistant) -> None: async def test_ecobee3_services_and_chars_removed( - hass: HomeAssistant, + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test handling removal of some services and chars.""" - entity_registry = er.async_get(hass) # Set up a base Ecobee 3 with additional sensors. accessories = await setup_accessories_from_file(hass, "ecobee3.json") diff --git a/tests/components/homekit_controller/specific_devices/test_fan_that_changes_features.py b/tests/components/homekit_controller/specific_devices/test_fan_that_changes_features.py index bae0c0e4ff1..1dc8e9ace68 100644 --- a/tests/components/homekit_controller/specific_devices/test_fan_that_changes_features.py +++ b/tests/components/homekit_controller/specific_devices/test_fan_that_changes_features.py @@ -13,9 +13,10 @@ from ..common import ( ) -async def test_fan_add_feature_at_runtime(hass: HomeAssistant) -> None: +async def test_fan_add_feature_at_runtime( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that new features can be added at runtime.""" - entity_registry = er.async_get(hass) # Set up a basic fan that does not support oscillation accessories = await setup_accessories_from_file( @@ -55,9 +56,10 @@ async def test_fan_add_feature_at_runtime(hass: HomeAssistant) -> None: assert fan_state.attributes[ATTR_SUPPORTED_FEATURES] is FanEntityFeature.SET_SPEED -async def test_fan_remove_feature_at_runtime(hass: HomeAssistant) -> None: +async def test_fan_remove_feature_at_runtime( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that features can be removed at runtime.""" - entity_registry = er.async_get(hass) # Set up a basic fan that does not support oscillation accessories = await setup_accessories_from_file( @@ -97,9 +99,11 @@ async def test_fan_remove_feature_at_runtime(hass: HomeAssistant) -> None: assert fan_state.attributes[ATTR_SUPPORTED_FEATURES] is FanEntityFeature.SET_SPEED -async def test_bridge_with_two_fans_one_removed(hass: HomeAssistant) -> None: +async def test_bridge_with_two_fans_one_removed( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Test a bridge with two fans and one gets removed.""" - entity_registry = er.async_get(hass) # Set up a basic fan that does not support oscillation accessories = await setup_accessories_from_file( diff --git a/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py b/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py index 9c51707b809..6d3c242c382 100644 --- a/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py +++ b/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py @@ -14,10 +14,12 @@ from ..common import ( ) -async def test_vocolinc_vp3_setup(hass: HomeAssistant) -> None: +async def test_vocolinc_vp3_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Test that a VOCOlinc VP3 can be correctly setup in HA.""" - entity_registry = er.async_get(hass) outlet = entity_registry.async_get_or_create( "switch", "homekit_controller", diff --git a/tests/components/homekit_controller/test_alarm_control_panel.py b/tests/components/homekit_controller/test_alarm_control_panel.py index 2ca74f8fe75..c38c3d47bfe 100644 --- a/tests/components/homekit_controller/test_alarm_control_panel.py +++ b/tests/components/homekit_controller/test_alarm_control_panel.py @@ -124,9 +124,10 @@ async def test_switch_read_alarm_state(hass: HomeAssistant, utcnow) -> None: assert state.state == "triggered" -async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test a we can migrate a alarm_control_panel unique id.""" - entity_registry = er.async_get(hass) aid = get_next_aid() alarm_control_panel_entry = entity_registry.async_get_or_create( "alarm_control_panel", diff --git a/tests/components/homekit_controller/test_binary_sensor.py b/tests/components/homekit_controller/test_binary_sensor.py index 0a1fd9fc52d..382d6182733 100644 --- a/tests/components/homekit_controller/test_binary_sensor.py +++ b/tests/components/homekit_controller/test_binary_sensor.py @@ -173,9 +173,10 @@ async def test_leak_sensor_read_state(hass: HomeAssistant, utcnow) -> None: assert state.attributes["device_class"] == BinarySensorDeviceClass.MOISTURE -async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test a we can migrate a binary_sensor unique id.""" - entity_registry = er.async_get(hass) aid = get_next_aid() binary_sensor_entry = entity_registry.async_get_or_create( "binary_sensor", diff --git a/tests/components/homekit_controller/test_button.py b/tests/components/homekit_controller/test_button.py index fd21498cf27..1f08b578a93 100644 --- a/tests/components/homekit_controller/test_button.py +++ b/tests/components/homekit_controller/test_button.py @@ -94,9 +94,10 @@ async def test_ecobee_clear_hold_press_button(hass: HomeAssistant) -> None: ) -async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test a we can migrate a button unique id.""" - entity_registry = er.async_get(hass) aid = get_next_aid() button_entry = entity_registry.async_get_or_create( "button", diff --git a/tests/components/homekit_controller/test_camera.py b/tests/components/homekit_controller/test_camera.py index 27bc470a953..bbb8e5a8eaa 100644 --- a/tests/components/homekit_controller/test_camera.py +++ b/tests/components/homekit_controller/test_camera.py @@ -16,9 +16,10 @@ def create_camera(accessory): accessory.add_service(ServicesTypes.CAMERA_RTP_STREAM_MANAGEMENT) -async def test_migrate_unique_ids(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_ids( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test migrating entity unique ids.""" - entity_registry = er.async_get(hass) aid = get_next_aid() camera = entity_registry.async_get_or_create( "camera", diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py index 0f6a3633bd4..c80016770fd 100644 --- a/tests/components/homekit_controller/test_climate.py +++ b/tests/components/homekit_controller/test_climate.py @@ -1112,9 +1112,10 @@ async def test_heater_cooler_turn_off(hass: HomeAssistant, utcnow) -> None: assert state.attributes["hvac_action"] == "off" -async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test a we can migrate a switch unique id.""" - entity_registry = er.async_get(hass) aid = get_next_aid() climate_entry = entity_registry.async_get_or_create( "climate", diff --git a/tests/components/homekit_controller/test_connection.py b/tests/components/homekit_controller/test_connection.py index e5949978215..08169c006ae 100644 --- a/tests/components/homekit_controller/test_connection.py +++ b/tests/components/homekit_controller/test_connection.py @@ -90,7 +90,9 @@ DEVICE_MIGRATION_TESTS = [ @pytest.mark.parametrize("variant", DEVICE_MIGRATION_TESTS) async def test_migrate_device_id_no_serial_skip_if_other_owner( - hass: HomeAssistant, variant: DeviceMigrationTest + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + variant: DeviceMigrationTest, ) -> None: """Don't migrate unrelated devices. @@ -99,7 +101,6 @@ async def test_migrate_device_id_no_serial_skip_if_other_owner( """ entry = MockConfigEntry() entry.add_to_hass(hass) - device_registry = dr.async_get(hass) bridge = device_registry.async_get_or_create( config_entry_id=entry.entry_id, @@ -122,11 +123,11 @@ async def test_migrate_device_id_no_serial_skip_if_other_owner( @pytest.mark.parametrize("variant", DEVICE_MIGRATION_TESTS) async def test_migrate_device_id_no_serial( - hass: HomeAssistant, variant: DeviceMigrationTest + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + variant: DeviceMigrationTest, ) -> None: """Test that a Ryse smart bridge with four shades can be migrated correctly in HA.""" - device_registry = dr.async_get(hass) - accessories = await setup_accessories_from_file(hass, variant.fixture) fake_controller = await setup_platform(hass) diff --git a/tests/components/homekit_controller/test_cover.py b/tests/components/homekit_controller/test_cover.py index 5a389311daa..49462a035e9 100644 --- a/tests/components/homekit_controller/test_cover.py +++ b/tests/components/homekit_controller/test_cover.py @@ -398,9 +398,10 @@ async def test_read_door_state(hass: HomeAssistant, utcnow) -> None: assert state.attributes["obstruction-detected"] is True -async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test a we can migrate a cover unique id.""" - entity_registry = er.async_get(hass) aid = get_next_aid() cover_entry = entity_registry.async_get_or_create( "cover", diff --git a/tests/components/homekit_controller/test_device_trigger.py b/tests/components/homekit_controller/test_device_trigger.py index 41b6a9fc7dc..ed3894c331b 100644 --- a/tests/components/homekit_controller/test_device_trigger.py +++ b/tests/components/homekit_controller/test_device_trigger.py @@ -83,15 +83,18 @@ def create_doorbell(accessory): battery.add_char(CharacteristicsTypes.BATTERY_LEVEL) -async def test_enumerate_remote(hass: HomeAssistant, utcnow) -> None: +async def test_enumerate_remote( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + utcnow, +) -> None: """Test that remote is correctly enumerated.""" await setup_test_component(hass, create_remote) - entity_registry = er.async_get(hass) bat_sensor = entity_registry.async_get("sensor.testdevice_battery") identify_button = entity_registry.async_get("button.testdevice_identify") - device_registry = dr.async_get(hass) device = device_registry.async_get(bat_sensor.device_id) expected = [ @@ -132,15 +135,18 @@ async def test_enumerate_remote(hass: HomeAssistant, utcnow) -> None: assert triggers == unordered(expected) -async def test_enumerate_button(hass: HomeAssistant, utcnow) -> None: +async def test_enumerate_button( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + utcnow, +) -> None: """Test that a button is correctly enumerated.""" await setup_test_component(hass, create_button) - entity_registry = er.async_get(hass) bat_sensor = entity_registry.async_get("sensor.testdevice_battery") identify_button = entity_registry.async_get("button.testdevice_identify") - device_registry = dr.async_get(hass) device = device_registry.async_get(bat_sensor.device_id) expected = [ @@ -180,15 +186,18 @@ async def test_enumerate_button(hass: HomeAssistant, utcnow) -> None: assert triggers == unordered(expected) -async def test_enumerate_doorbell(hass: HomeAssistant, utcnow) -> None: +async def test_enumerate_doorbell( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + utcnow, +) -> None: """Test that a button is correctly enumerated.""" await setup_test_component(hass, create_doorbell) - entity_registry = er.async_get(hass) bat_sensor = entity_registry.async_get("sensor.testdevice_battery") identify_button = entity_registry.async_get("button.testdevice_identify") - device_registry = dr.async_get(hass) device = device_registry.async_get(bat_sensor.device_id) expected = [ @@ -228,14 +237,18 @@ async def test_enumerate_doorbell(hass: HomeAssistant, utcnow) -> None: assert triggers == unordered(expected) -async def test_handle_events(hass: HomeAssistant, utcnow, calls) -> None: +async def test_handle_events( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + utcnow, + calls, +) -> None: """Test that events are handled.""" helper = await setup_test_component(hass, create_remote) - entity_registry = er.async_get(hass) entry = entity_registry.async_get("sensor.testdevice_battery") - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert await async_setup_component( @@ -345,14 +358,18 @@ async def test_handle_events(hass: HomeAssistant, utcnow, calls) -> None: assert len(calls) == 2 -async def test_handle_events_late_setup(hass: HomeAssistant, utcnow, calls) -> None: +async def test_handle_events_late_setup( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + utcnow, + calls, +) -> None: """Test that events are handled when setup happens after startup.""" helper = await setup_test_component(hass, create_remote) - entity_registry = er.async_get(hass) entry = entity_registry.async_get("sensor.testdevice_battery") - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) await hass.config_entries.async_unload(helper.config_entry.entry_id) diff --git a/tests/components/homekit_controller/test_diagnostics.py b/tests/components/homekit_controller/test_diagnostics.py index 4b5372d980d..0f1073b877d 100644 --- a/tests/components/homekit_controller/test_diagnostics.py +++ b/tests/components/homekit_controller/test_diagnostics.py @@ -290,14 +290,16 @@ async def test_config_entry( async def test_device( - hass: HomeAssistant, hass_client: ClientSessionGenerator, utcnow + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, + utcnow, ) -> None: """Test generating diagnostics for a device entry.""" accessories = await setup_accessories_from_file(hass, "koogeek_ls1.json") config_entry, _ = await setup_test_accessories(hass, accessories) connection = hass.data[KNOWN_DEVICES]["00:00:00:00:00:00"] - device_registry = dr.async_get(hass) device = device_registry.async_get(connection.devices[1]) diag = await get_diagnostics_for_device(hass, hass_client, config_entry, device) diff --git a/tests/components/homekit_controller/test_event.py b/tests/components/homekit_controller/test_event.py index 9731f429eaf..7fb0d1fd55f 100644 --- a/tests/components/homekit_controller/test_event.py +++ b/tests/components/homekit_controller/test_event.py @@ -64,7 +64,9 @@ def create_doorbell(accessory): battery.add_char(CharacteristicsTypes.BATTERY_LEVEL) -async def test_remote(hass: HomeAssistant, utcnow) -> None: +async def test_remote( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test that remote is supported.""" helper = await setup_test_component(hass, create_remote) @@ -75,8 +77,6 @@ async def test_remote(hass: HomeAssistant, utcnow) -> None: ("event.testdevice_button_4", "Button 4"), ] - entity_registry = er.async_get(hass) - for entity_id, service in entities: button = entity_registry.async_get(entity_id) @@ -109,12 +109,13 @@ async def test_remote(hass: HomeAssistant, utcnow) -> None: assert state.attributes["event_type"] == "long_press" -async def test_button(hass: HomeAssistant, utcnow) -> None: +async def test_button( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test that a button is correctly enumerated.""" helper = await setup_test_component(hass, create_button) entity_id = "event.testdevice_button_1" - entity_registry = er.async_get(hass) button = entity_registry.async_get(entity_id) assert button.original_device_class == EventDeviceClass.BUTTON @@ -146,12 +147,13 @@ async def test_button(hass: HomeAssistant, utcnow) -> None: assert state.attributes["event_type"] == "long_press" -async def test_doorbell(hass: HomeAssistant, utcnow) -> None: +async def test_doorbell( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test that doorbell service is handled.""" helper = await setup_test_component(hass, create_doorbell) entity_id = "event.testdevice_doorbell" - entity_registry = er.async_get(hass) doorbell = entity_registry.async_get(entity_id) assert doorbell.original_device_class == EventDeviceClass.DOORBELL diff --git a/tests/components/homekit_controller/test_fan.py b/tests/components/homekit_controller/test_fan.py index 9256128b2cb..2fb64fc345d 100644 --- a/tests/components/homekit_controller/test_fan.py +++ b/tests/components/homekit_controller/test_fan.py @@ -811,9 +811,10 @@ async def test_v2_set_percentage_non_standard_rotation_range( ) -async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test a we can migrate a fan unique id.""" - entity_registry = er.async_get(hass) aid = get_next_aid() fan_entry = entity_registry.async_get_or_create( "fan", diff --git a/tests/components/homekit_controller/test_humidifier.py b/tests/components/homekit_controller/test_humidifier.py index e412fed0878..718c6957356 100644 --- a/tests/components/homekit_controller/test_humidifier.py +++ b/tests/components/homekit_controller/test_humidifier.py @@ -455,11 +455,12 @@ async def test_dehumidifier_target_humidity_modes(hass: HomeAssistant, utcnow) - assert state.attributes["current_humidity"] == 51 -async def test_migrate_entity_ids(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_entity_ids( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test that we can migrate humidifier entity ids.""" aid = get_next_aid() - entity_registry = er.async_get(hass) humidifier_entry = entity_registry.async_get_or_create( "humidifier", "homekit_controller", diff --git a/tests/components/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index 23c6e245ac7..7f7bec3bb2f 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -17,7 +17,6 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -85,7 +84,10 @@ def create_alive_service(accessory): async def test_device_remove_devices( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, ) -> None: """Test we can only remove a device that no longer exists.""" assert await async_setup_component(hass, "config", {}) @@ -93,9 +95,7 @@ async def test_device_remove_devices( config_entry = helper.config_entry entry_id = config_entry.entry_id - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities[ALIVE_DEVICE_ENTITY_ID] - device_registry = dr.async_get(hass) + entity = entity_registry.entities[ALIVE_DEVICE_ENTITY_ID] live_device_entry = device_registry.async_get(entity.device_id) assert ( @@ -231,15 +231,16 @@ async def test_ble_device_only_checks_is_available( @pytest.mark.parametrize("example", FIXTURES, ids=lambda val: str(val.stem)) async def test_snapshots( - hass: HomeAssistant, snapshot: SnapshotAssertion, example: str + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + example: str, ) -> None: """Detect regressions in enumerating a homekit accessory database and building entities.""" accessories = await setup_accessories_from_file(hass, example) config_entry, _ = await setup_test_accessories(hass, accessories) - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - registry_devices = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) diff --git a/tests/components/homekit_controller/test_light.py b/tests/components/homekit_controller/test_light.py index d6b36fca22e..5d33d744de7 100644 --- a/tests/components/homekit_controller/test_light.py +++ b/tests/components/homekit_controller/test_light.py @@ -343,9 +343,10 @@ async def test_light_unloaded_removed(hass: HomeAssistant, utcnow) -> None: assert hass.states.get(helper.entity_id).state == STATE_UNAVAILABLE -async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test a we can migrate a light unique id.""" - entity_registry = er.async_get(hass) aid = get_next_aid() light_entry = entity_registry.async_get_or_create( "light", @@ -360,9 +361,10 @@ async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: ) -async def test_only_migrate_once(hass: HomeAssistant, utcnow) -> None: +async def test_only_migrate_once( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test a we handle migration happening after an upgrade and than a downgrade and then an upgrade.""" - entity_registry = er.async_get(hass) aid = get_next_aid() old_light_entry = entity_registry.async_get_or_create( "light", diff --git a/tests/components/homekit_controller/test_lock.py b/tests/components/homekit_controller/test_lock.py index 20a18d1acbe..e265bf586a2 100644 --- a/tests/components/homekit_controller/test_lock.py +++ b/tests/components/homekit_controller/test_lock.py @@ -117,9 +117,10 @@ async def test_switch_read_lock_state(hass: HomeAssistant, utcnow) -> None: assert state.state == "unlocking" -async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test a we can migrate a lock unique id.""" - entity_registry = er.async_get(hass) aid = get_next_aid() lock_entry = entity_registry.async_get_or_create( "lock", diff --git a/tests/components/homekit_controller/test_media_player.py b/tests/components/homekit_controller/test_media_player.py index 140b722d3ab..e9ea1d552ce 100644 --- a/tests/components/homekit_controller/test_media_player.py +++ b/tests/components/homekit_controller/test_media_player.py @@ -368,9 +368,10 @@ async def test_tv_set_source_fail(hass: HomeAssistant, utcnow) -> None: assert state.attributes["source"] == "HDMI 1" -async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test a we can migrate a media_player unique id.""" - entity_registry = er.async_get(hass) aid = get_next_aid() media_player_entry = entity_registry.async_get_or_create( "media_player", diff --git a/tests/components/homekit_controller/test_number.py b/tests/components/homekit_controller/test_number.py index a95239c23df..dedff37fa4b 100644 --- a/tests/components/homekit_controller/test_number.py +++ b/tests/components/homekit_controller/test_number.py @@ -29,9 +29,10 @@ def create_switch_with_spray_level(accessory): return service -async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test a we can migrate a number unique id.""" - entity_registry = er.async_get(hass) aid = get_next_aid() number = entity_registry.async_get_or_create( "number", diff --git a/tests/components/homekit_controller/test_select.py b/tests/components/homekit_controller/test_select.py index 9cfa0bccda3..70228ef3dbb 100644 --- a/tests/components/homekit_controller/test_select.py +++ b/tests/components/homekit_controller/test_select.py @@ -33,9 +33,10 @@ def create_service_with_temperature_units(accessory: Accessory): return service -async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test we can migrate a select unique id.""" - entity_registry = er.async_get(hass) aid = get_next_aid() select = entity_registry.async_get_or_create( "select", diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py index 829fe8e3cdc..e15227d9d87 100644 --- a/tests/components/homekit_controller/test_sensor.py +++ b/tests/components/homekit_controller/test_sensor.py @@ -409,12 +409,12 @@ async def test_rssi_sensor( async def test_migrate_rssi_sensor_unique_id( hass: HomeAssistant, + entity_registry: er.EntityRegistry, utcnow, entity_registry_enabled_by_default: None, enable_bluetooth: None, ) -> None: """Test an rssi sensor unique id migration.""" - entity_registry = er.async_get(hass) rssi_sensor = entity_registry.async_get_or_create( "sensor", "homekit_controller", diff --git a/tests/components/homekit_controller/test_switch.py b/tests/components/homekit_controller/test_switch.py index 34003984557..8867ffc9bd1 100644 --- a/tests/components/homekit_controller/test_switch.py +++ b/tests/components/homekit_controller/test_switch.py @@ -219,9 +219,10 @@ async def test_char_switch_read_state(hass: HomeAssistant, utcnow) -> None: assert switch_1.state == "off" -async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: +async def test_migrate_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow +) -> None: """Test a we can migrate a switch unique id.""" - entity_registry = er.async_get(hass) aid = get_next_aid() switch_entry = entity_registry.async_get_or_create( "switch", diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 24842ab8beb..b1f063615f3 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -29,7 +29,10 @@ async def test_hmip_load_all_supported_devices( async def test_hmip_remove_device( - hass: HomeAssistant, default_mock_hap_factory + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + default_mock_hap_factory, ) -> None: """Test Remove of hmip device.""" entity_id = "light.treppe_ch" @@ -46,9 +49,6 @@ async def test_hmip_remove_device( assert ha_state.state == STATE_ON assert hmip_device - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - pre_device_count = len(device_registry.devices) pre_entity_count = len(entity_registry.entities) pre_mapping_count = len(mock_hap.hmip_device_by_entity_id) @@ -63,7 +63,11 @@ async def test_hmip_remove_device( async def test_hmip_add_device( - hass: HomeAssistant, default_mock_hap_factory, hmip_config_entry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + default_mock_hap_factory, + hmip_config_entry, ) -> None: """Test Remove of hmip device.""" entity_id = "light.treppe_ch" @@ -80,9 +84,6 @@ async def test_hmip_add_device( assert ha_state.state == STATE_ON assert hmip_device - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - pre_device_count = len(device_registry.devices) pre_entity_count = len(entity_registry.entities) pre_mapping_count = len(mock_hap.hmip_device_by_entity_id) @@ -101,7 +102,7 @@ async def test_hmip_add_device( ), patch.object(reloaded_hap, "async_connect"), patch.object( reloaded_hap, "get_hap", return_value=mock_hap.home ), patch( - "homeassistant.components.homematicip_cloud.hap.asyncio.sleep" + "homeassistant.components.homematicip_cloud.hap.asyncio.sleep", ): mock_hap.home.fire_create_event(event_type=EventType.DEVICE_ADDED) await hass.async_block_till_done() @@ -112,7 +113,12 @@ async def test_hmip_add_device( assert len(new_hap.hmip_device_by_entity_id) == pre_mapping_count -async def test_hmip_remove_group(hass: HomeAssistant, default_mock_hap_factory) -> None: +async def test_hmip_remove_group( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + default_mock_hap_factory, +) -> None: """Test Remove of hmip group.""" entity_id = "switch.strom_group" entity_name = "Strom Group" @@ -126,9 +132,6 @@ async def test_hmip_remove_group(hass: HomeAssistant, default_mock_hap_factory) assert ha_state.state == STATE_ON assert hmip_device - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - pre_device_count = len(device_registry.devices) pre_entity_count = len(entity_registry.entities) pre_mapping_count = len(mock_hap.hmip_device_by_entity_id) @@ -254,7 +257,10 @@ async def test_hmip_reset_energy_counter_services( async def test_hmip_multi_area_device( - hass: HomeAssistant, default_mock_hap_factory + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + default_mock_hap_factory, ) -> None: """Test multi area device. Check if devices are created and referenced.""" entity_id = "binary_sensor.wired_eingangsmodul_32_fach_channel5" @@ -270,12 +276,10 @@ async def test_hmip_multi_area_device( assert ha_state # get the entity - entity_registry = er.async_get(hass) entity = entity_registry.async_get(ha_state.entity_id) assert entity # get the device - device_registry = dr.async_get(hass) device = device_registry.async_get(entity.device_id) assert device.name == "Wired Eingangsmodul – 32-fach" diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index 4569a6fff6b..0d950968191 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -53,7 +53,8 @@ async def test_auth_auth_check_and_register(hass: HomeAssistant) -> None: ), patch.object( hmip_auth.auth, "requestAuthToken", return_value="ABC" ), patch.object( - hmip_auth.auth, "confirmAuthToken" + hmip_auth.auth, + "confirmAuthToken", ): assert await hmip_auth.async_checkbutton() assert await hmip_auth.async_register() == "ABC" diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index 4cfec96cb8f..e778c82928b 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator import json from unittest.mock import AsyncMock, MagicMock, patch +from homewizard_energy.errors import NotFoundError from homewizard_energy.models import Data, Device, State, System import pytest @@ -10,62 +11,78 @@ from homeassistant.components.homewizard.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, get_fixture_path, load_fixture @pytest.fixture -def mock_config_entry_data(): - """Return the default mocked config entry data.""" - return { - "product_name": "Product Name", - "product_type": "product_type", - "serial": "aabbccddeeff", - "name": "Product Name", - CONF_IP_ADDRESS: "1.2.3.4", - } +def device_fixture() -> str: + """Return the device fixtures for a specific device.""" + return "HWE-P1" + + +@pytest.fixture +def mock_homewizardenergy( + device_fixture: str, +) -> MagicMock: + """Return a mock bridge.""" + with patch( + "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", + autospec=True, + ) as homewizard, patch( + "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", + new=homewizard, + ): + client = homewizard.return_value + + client.device.return_value = Device.from_dict( + json.loads(load_fixture(f"{device_fixture}/device.json", DOMAIN)) + ) + client.data.return_value = Data.from_dict( + json.loads(load_fixture(f"{device_fixture}/data.json", DOMAIN)) + ) + + if get_fixture_path(f"{device_fixture}/state.json", DOMAIN).exists(): + client.state.return_value = State.from_dict( + json.loads(load_fixture(f"{device_fixture}/state.json", DOMAIN)) + ) + else: + client.state.side_effect = NotFoundError + + if get_fixture_path(f"{device_fixture}/system.json", DOMAIN).exists(): + client.system.return_value = System.from_dict( + json.loads(load_fixture(f"{device_fixture}/system.json", DOMAIN)) + ) + else: + client.system.side_effect = NotFoundError + + yield client + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.homewizard.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" return MockConfigEntry( - title="Product Name (aabbccddeeff)", + title="Device", domain=DOMAIN, - data={CONF_IP_ADDRESS: "1.2.3.4"}, + data={ + "product_name": "Product name", + "product_type": "product_type", + "serial": "aabbccddeeff", + CONF_IP_ADDRESS: "127.0.0.1", + }, unique_id="aabbccddeeff", ) -@pytest.fixture -def mock_homewizardenergy(): - """Return a mocked all-feature device.""" - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - ) as device: - client = device.return_value - client.device = AsyncMock( - side_effect=lambda: Device.from_dict( - json.loads(load_fixture("homewizard/device.json")) - ) - ) - client.data = AsyncMock( - side_effect=lambda: Data.from_dict( - json.loads(load_fixture("homewizard/data.json")) - ) - ) - client.state = AsyncMock( - side_effect=lambda: State.from_dict( - json.loads(load_fixture("homewizard/state.json")) - ) - ) - client.system = AsyncMock( - side_effect=lambda: System.from_dict( - json.loads(load_fixture("homewizard/system.json")) - ) - ) - yield device - - @pytest.fixture async def init_integration( hass: HomeAssistant, diff --git a/tests/components/homewizard/fixtures/HWE-P1-unused-exports/data.json b/tests/components/homewizard/fixtures/HWE-P1-unused-exports/data.json new file mode 100644 index 00000000000..03cadf99ec5 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-P1-unused-exports/data.json @@ -0,0 +1,45 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_strength": 100, + "smr_version": 50, + "meter_model": "ISKRA 2M550T-101", + "unique_id": "00112233445566778899AABBCCDDEEFF", + "active_tariff": 2, + "total_power_import_kwh": 13779.338, + "total_power_import_t1_kwh": 10830.511, + "total_power_import_t2_kwh": 2948.827, + "total_power_import_t3_kwh": 2948.827, + "total_power_import_t4_kwh": 2948.827, + "total_power_export_kwh": 0, + "total_power_export_t1_kwh": 0, + "total_power_export_t2_kwh": 0, + "total_power_export_t3_kwh": 0, + "total_power_export_t4_kwh": 0, + "active_power_w": -123, + "active_power_l1_w": -123, + "active_power_l2_w": 456, + "active_power_l3_w": 123.456, + "active_voltage_l1_v": 230.111, + "active_voltage_l2_v": 230.222, + "active_voltage_l3_v": 230.333, + "active_current_l1_a": -4, + "active_current_l2_a": 2, + "active_current_l3_a": 0, + "active_frequency_hz": 50, + "voltage_sag_l1_count": 1, + "voltage_sag_l2_count": 2, + "voltage_sag_l3_count": 3, + "voltage_swell_l1_count": 4, + "voltage_swell_l2_count": 5, + "voltage_swell_l3_count": 6, + "any_power_fail_count": 4, + "long_power_fail_count": 5, + "total_gas_m3": 1122.333, + "gas_timestamp": 210314112233, + "gas_unique_id": "01FFEEDDCCBBAA99887766554433221100", + "active_power_average_w": 123.0, + "montly_power_peak_w": 1111.0, + "montly_power_peak_timestamp": 230101080010, + "active_liter_lpm": 12.345, + "total_liter_m3": 1234.567 +} diff --git a/tests/components/homewizard/fixtures/HWE-P1-unused-exports/device.json b/tests/components/homewizard/fixtures/HWE-P1-unused-exports/device.json new file mode 100644 index 00000000000..4972c491859 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-P1-unused-exports/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-P1", + "product_name": "P1 meter", + "serial": "3c39e7aabbcc", + "firmware_version": "4.19", + "api_version": "v1" +} diff --git a/tests/components/homewizard/fixtures/system.json b/tests/components/homewizard/fixtures/HWE-P1-unused-exports/system.json similarity index 100% rename from tests/components/homewizard/fixtures/system.json rename to tests/components/homewizard/fixtures/HWE-P1-unused-exports/system.json diff --git a/tests/components/homewizard/fixtures/HWE-P1-zero-values/data.json b/tests/components/homewizard/fixtures/HWE-P1-zero-values/data.json new file mode 100644 index 00000000000..d21b4ed2d4a --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-P1-zero-values/data.json @@ -0,0 +1,45 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_strength": 100, + "smr_version": 50, + "meter_model": "ISKRA 2M550T-101", + "unique_id": "00112233445566778899AABBCCDDEEFF", + "active_tariff": 2, + "total_power_import_kwh": 0.0, + "total_power_import_t1_kwh": 0.0, + "total_power_import_t2_kwh": 0.0, + "total_power_import_t3_kwh": 0.0, + "total_power_import_t4_kwh": 0.0, + "total_power_export_kwh": 0.0, + "total_power_export_t1_kwh": 0.0, + "total_power_export_t2_kwh": 0.0, + "total_power_export_t3_kwh": 0.0, + "total_power_export_t4_kwh": 0.0, + "active_power_w": 0.0, + "active_power_l1_w": 0.0, + "active_power_l2_w": 0.0, + "active_power_l3_w": 0.0, + "active_voltage_l1_v": 0.0, + "active_voltage_l2_v": 0.0, + "active_voltage_l3_v": 0.0, + "active_current_l1_a": 0, + "active_current_l2_a": 0, + "active_current_l3_a": 0, + "active_frequency_hz": 0, + "voltage_sag_l1_count": 0, + "voltage_sag_l2_count": 0, + "voltage_sag_l3_count": 0, + "voltage_swell_l1_count": 0, + "voltage_swell_l2_count": 0, + "voltage_swell_l3_count": 0, + "any_power_fail_count": 0, + "long_power_fail_count": 0, + "total_gas_m3": 0.0, + "gas_timestamp": 210314112233, + "gas_unique_id": "01FFEEDDCCBBAA99887766554433221100", + "active_power_average_w": 0, + "montly_power_peak_w": 0.0, + "montly_power_peak_timestamp": 230101080010, + "active_liter_lpm": 0.0, + "total_liter_m3": 0.0 +} diff --git a/tests/components/homewizard/fixtures/HWE-P1-zero-values/device.json b/tests/components/homewizard/fixtures/HWE-P1-zero-values/device.json new file mode 100644 index 00000000000..4972c491859 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-P1-zero-values/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-P1", + "product_name": "P1 meter", + "serial": "3c39e7aabbcc", + "firmware_version": "4.19", + "api_version": "v1" +} diff --git a/tests/components/homewizard/fixtures/HWE-P1-zero-values/system.json b/tests/components/homewizard/fixtures/HWE-P1-zero-values/system.json new file mode 100644 index 00000000000..362491b3519 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-P1-zero-values/system.json @@ -0,0 +1,3 @@ +{ + "cloud_enabled": true +} diff --git a/tests/components/homewizard/fixtures/data.json b/tests/components/homewizard/fixtures/HWE-P1/data.json similarity index 88% rename from tests/components/homewizard/fixtures/data.json rename to tests/components/homewizard/fixtures/HWE-P1/data.json index f73d3ac1a19..2eb7e3e430b 100644 --- a/tests/components/homewizard/fixtures/data.json +++ b/tests/components/homewizard/fixtures/HWE-P1/data.json @@ -8,9 +8,13 @@ "total_power_import_kwh": 13779.338, "total_power_import_t1_kwh": 10830.511, "total_power_import_t2_kwh": 2948.827, + "total_power_import_t3_kwh": 2948.827, + "total_power_import_t4_kwh": 2948.827, "total_power_export_kwh": 13086.777, "total_power_export_t1_kwh": 4321.333, "total_power_export_t2_kwh": 8765.444, + "total_power_export_t3_kwh": 8765.444, + "total_power_export_t4_kwh": 8765.444, "active_power_w": -123, "active_power_l1_w": -123, "active_power_l2_w": 456, diff --git a/tests/components/homewizard/fixtures/HWE-P1/device.json b/tests/components/homewizard/fixtures/HWE-P1/device.json new file mode 100644 index 00000000000..4972c491859 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-P1/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-P1", + "product_name": "P1 meter", + "serial": "3c39e7aabbcc", + "firmware_version": "4.19", + "api_version": "v1" +} diff --git a/tests/components/homewizard/fixtures/HWE-P1/system.json b/tests/components/homewizard/fixtures/HWE-P1/system.json new file mode 100644 index 00000000000..362491b3519 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-P1/system.json @@ -0,0 +1,3 @@ +{ + "cloud_enabled": true +} diff --git a/tests/components/homewizard/fixtures/HWE-SKT/data.json b/tests/components/homewizard/fixtures/HWE-SKT/data.json new file mode 100644 index 00000000000..f2a465bd40d --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-SKT/data.json @@ -0,0 +1,8 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_strength": 94, + "total_power_import_t1_kwh": 63.651, + "total_power_export_t1_kwh": 0, + "active_power_w": 1457.277, + "active_power_l1_w": 1457.277 +} diff --git a/tests/components/homewizard/fixtures/device.json b/tests/components/homewizard/fixtures/HWE-SKT/device.json similarity index 56% rename from tests/components/homewizard/fixtures/device.json rename to tests/components/homewizard/fixtures/HWE-SKT/device.json index 2e5be55c68e..bab5a636368 100644 --- a/tests/components/homewizard/fixtures/device.json +++ b/tests/components/homewizard/fixtures/HWE-SKT/device.json @@ -1,7 +1,7 @@ { "product_type": "HWE-SKT", - "product_name": "P1 Meter", + "product_name": "Energy Socket", "serial": "3c39e7aabbcc", - "firmware_version": "2.11", + "firmware_version": "3.03", "api_version": "v1" } diff --git a/tests/components/homewizard/fixtures/state.json b/tests/components/homewizard/fixtures/HWE-SKT/state.json similarity index 100% rename from tests/components/homewizard/fixtures/state.json rename to tests/components/homewizard/fixtures/HWE-SKT/state.json diff --git a/tests/components/homewizard/fixtures/HWE-SKT/system.json b/tests/components/homewizard/fixtures/HWE-SKT/system.json new file mode 100644 index 00000000000..362491b3519 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-SKT/system.json @@ -0,0 +1,3 @@ +{ + "cloud_enabled": true +} diff --git a/tests/components/homewizard/fixtures/HWE-WTR/data.json b/tests/components/homewizard/fixtures/HWE-WTR/data.json new file mode 100644 index 00000000000..16097742891 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-WTR/data.json @@ -0,0 +1,6 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_strength": 84, + "active_liter_lpm": 0, + "total_liter_m3": 17.014 +} diff --git a/tests/components/homewizard/fixtures/HWE-WTR/device.json b/tests/components/homewizard/fixtures/HWE-WTR/device.json new file mode 100644 index 00000000000..d33e6045299 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-WTR/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-WTR", + "product_name": "Watermeter", + "serial": "3c39e7aabbcc", + "firmware_version": "2.03", + "api_version": "v1" +} diff --git a/tests/components/homewizard/fixtures/SDM230/data.json b/tests/components/homewizard/fixtures/SDM230/data.json new file mode 100644 index 00000000000..64fb2533359 --- /dev/null +++ b/tests/components/homewizard/fixtures/SDM230/data.json @@ -0,0 +1,8 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_strength": 92, + "total_power_import_t1_kwh": 2.705, + "total_power_export_t1_kwh": 255.551, + "active_power_w": -1058.296, + "active_power_l1_w": -1058.296 +} diff --git a/tests/components/homewizard/fixtures/SDM230/device.json b/tests/components/homewizard/fixtures/SDM230/device.json new file mode 100644 index 00000000000..b6b5c18904e --- /dev/null +++ b/tests/components/homewizard/fixtures/SDM230/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "SDM230-wifi", + "product_name": "kWh meter", + "serial": "3c39e7aabbcc", + "firmware_version": "3.06", + "api_version": "v1" +} diff --git a/tests/components/homewizard/fixtures/SDM230/system.json b/tests/components/homewizard/fixtures/SDM230/system.json new file mode 100644 index 00000000000..362491b3519 --- /dev/null +++ b/tests/components/homewizard/fixtures/SDM230/system.json @@ -0,0 +1,3 @@ +{ + "cloud_enabled": true +} diff --git a/tests/components/homewizard/fixtures/SDM630/data.json b/tests/components/homewizard/fixtures/SDM630/data.json new file mode 100644 index 00000000000..ee143220c67 --- /dev/null +++ b/tests/components/homewizard/fixtures/SDM630/data.json @@ -0,0 +1,10 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_strength": 92, + "total_power_import_t1_kwh": 0.101, + "total_power_export_t1_kwh": 0.523, + "active_power_w": -900.194, + "active_power_l1_w": -1058.296, + "active_power_l2_w": 158.102, + "active_power_l3_w": 0.0 +} diff --git a/tests/components/homewizard/fixtures/SDM630/device.json b/tests/components/homewizard/fixtures/SDM630/device.json new file mode 100644 index 00000000000..b8ec1d18fe8 --- /dev/null +++ b/tests/components/homewizard/fixtures/SDM630/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "SDM630-wifi", + "product_name": "KWh meter 3-phase", + "serial": "3c39e7aabbcc", + "firmware_version": "3.06", + "api_version": "v1" +} diff --git a/tests/components/homewizard/fixtures/SDM630/system.json b/tests/components/homewizard/fixtures/SDM630/system.json new file mode 100644 index 00000000000..362491b3519 --- /dev/null +++ b/tests/components/homewizard/fixtures/SDM630/system.json @@ -0,0 +1,3 @@ +{ + "cloud_enabled": true +} diff --git a/tests/components/homewizard/generator.py b/tests/components/homewizard/generator.py deleted file mode 100644 index 6eb945334fd..00000000000 --- a/tests/components/homewizard/generator.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Helper files for unit tests.""" - -from unittest.mock import AsyncMock - -from homewizard_energy.models import Data, Device - - -def get_mock_device( - serial="aabbccddeeff", - host="1.2.3.4", - product_name="P1 meter", - product_type="HWE-P1", - firmware_version="1.00", -): - """Return a mock bridge.""" - mock_device = AsyncMock() - mock_device.host = host - - mock_device.device = AsyncMock( - return_value=Device( - product_name=product_name, - product_type=product_type, - serial=serial, - api_version="V1", - firmware_version=firmware_version, - ) - ) - mock_device.data = AsyncMock(return_value=Data.from_dict({})) - mock_device.state = AsyncMock(return_value=None) - mock_device.system = AsyncMock(return_value=None) - - mock_device.close = AsyncMock() - - return mock_device diff --git a/tests/components/homewizard/snapshots/test_button.ambr b/tests/components/homewizard/snapshots/test_button.ambr new file mode 100644 index 00000000000..2e6422f7a2d --- /dev/null +++ b/tests/components/homewizard/snapshots/test_button.ambr @@ -0,0 +1,77 @@ +# serializer version: 1 +# name: test_identify_button + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'Device Identify', + }), + 'context': , + 'entity_id': 'button.device_identify', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_identify_button.1 + 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.device_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_identify', + 'unit_of_measurement': None, + }) +# --- +# name: test_identify_button.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/homewizard/snapshots/test_config_flow.ambr b/tests/components/homewizard/snapshots/test_config_flow.ambr new file mode 100644 index 00000000000..b5b7411532e --- /dev/null +++ b/tests/components/homewizard/snapshots/test_config_flow.ambr @@ -0,0 +1,153 @@ +# serializer version: 1 +# name: test_discovery_flow_during_onboarding + FlowResultSnapshot({ + 'context': dict({ + 'source': 'zeroconf', + 'unique_id': 'HWE-P1_aabbccddeeff', + }), + 'data': dict({ + 'ip_address': '127.0.0.1', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'homewizard', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'ip_address': '127.0.0.1', + }), + 'disabled_by': None, + 'domain': 'homewizard', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'zeroconf', + 'title': 'P1 meter', + 'unique_id': 'HWE-P1_aabbccddeeff', + 'version': 1, + }), + 'title': 'P1 meter', + 'type': , + 'version': 1, + }) +# --- +# name: test_discovery_flow_during_onboarding_disabled_api + FlowResultSnapshot({ + 'context': dict({ + 'confirm_only': True, + 'source': 'zeroconf', + 'title_placeholders': dict({ + 'name': 'P1 meter', + }), + 'unique_id': 'HWE-P1_aabbccddeeff', + }), + 'data': dict({ + 'ip_address': '127.0.0.1', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'homewizard', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'ip_address': '127.0.0.1', + }), + 'disabled_by': None, + 'domain': 'homewizard', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'zeroconf', + 'title': 'P1 meter', + 'unique_id': 'HWE-P1_aabbccddeeff', + 'version': 1, + }), + 'title': 'P1 meter', + 'type': , + 'version': 1, + }) +# --- +# name: test_discovery_flow_works + FlowResultSnapshot({ + 'context': dict({ + 'confirm_only': True, + 'source': 'zeroconf', + 'title_placeholders': dict({ + 'name': 'Energy Socket (aabbccddeeff)', + }), + 'unique_id': 'HWE-SKT_aabbccddeeff', + }), + 'data': dict({ + 'ip_address': '127.0.0.1', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'homewizard', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'ip_address': '127.0.0.1', + }), + 'disabled_by': None, + 'domain': 'homewizard', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'zeroconf', + 'title': 'Energy Socket', + 'unique_id': 'HWE-SKT_aabbccddeeff', + 'version': 1, + }), + 'title': 'Energy Socket', + 'type': , + 'version': 1, + }) +# --- +# name: test_manual_flow_works + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + 'unique_id': 'HWE-P1_3c39e7aabbcc', + }), + 'data': dict({ + 'ip_address': '2.2.2.2', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'homewizard', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'ip_address': '2.2.2.2', + }), + 'disabled_by': None, + 'domain': 'homewizard', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'P1 meter', + 'unique_id': 'HWE-P1_3c39e7aabbcc', + 'version': 1, + }), + 'title': 'P1 meter', + 'type': , + 'version': 1, + }) +# --- diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index 5e1025a8d31..01094ec2698 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_diagnostics +# name: test_diagnostics[HWE-P1] dict({ 'data': dict({ 'data': dict({ @@ -26,18 +26,18 @@ 'monthly_power_peak_timestamp': '2023-01-01T08:00:10', 'monthly_power_peak_w': 1111.0, 'smr_version': 50, + 'total_energy_export_kwh': 13086.777, + 'total_energy_export_t1_kwh': 4321.333, + 'total_energy_export_t2_kwh': 8765.444, + 'total_energy_export_t3_kwh': 8765.444, + 'total_energy_export_t4_kwh': 8765.444, + 'total_energy_import_kwh': 13779.338, + 'total_energy_import_t1_kwh': 10830.511, + 'total_energy_import_t2_kwh': 2948.827, + 'total_energy_import_t3_kwh': 2948.827, + 'total_energy_import_t4_kwh': 2948.827, 'total_gas_m3': 1122.333, 'total_liter_m3': 1234.567, - 'total_power_export_kwh': 13086.777, - 'total_power_export_t1_kwh': 4321.333, - 'total_power_export_t2_kwh': 8765.444, - 'total_power_export_t3_kwh': None, - 'total_power_export_t4_kwh': None, - 'total_power_import_kwh': 13779.338, - 'total_power_import_t1_kwh': 10830.511, - 'total_power_import_t2_kwh': 2948.827, - 'total_power_import_t3_kwh': None, - 'total_power_import_t4_kwh': None, 'unique_meter_id': '**REDACTED**', 'voltage_sag_l1_count': 1, 'voltage_sag_l2_count': 2, @@ -50,8 +50,77 @@ }), 'device': dict({ 'api_version': 'v1', - 'firmware_version': '2.11', - 'product_name': 'P1 Meter', + 'firmware_version': '4.19', + 'product_name': 'P1 meter', + 'product_type': 'HWE-P1', + 'serial': '**REDACTED**', + }), + 'state': None, + 'system': dict({ + 'cloud_enabled': True, + }), + }), + 'entry': dict({ + 'ip_address': '**REDACTED**', + 'product_name': 'Product name', + 'product_type': 'product_type', + 'serial': '**REDACTED**', + }), + }) +# --- +# name: test_diagnostics[HWE-SKT] + dict({ + 'data': dict({ + 'data': dict({ + 'active_current_l1_a': None, + 'active_current_l2_a': None, + 'active_current_l3_a': None, + 'active_frequency_hz': None, + 'active_liter_lpm': None, + 'active_power_average_w': None, + 'active_power_l1_w': 1457.277, + 'active_power_l2_w': None, + 'active_power_l3_w': None, + 'active_power_w': 1457.277, + 'active_tariff': None, + 'active_voltage_l1_v': None, + 'active_voltage_l2_v': None, + 'active_voltage_l3_v': None, + 'any_power_fail_count': None, + 'external_devices': None, + 'gas_timestamp': None, + 'gas_unique_id': None, + 'long_power_fail_count': None, + 'meter_model': None, + 'monthly_power_peak_timestamp': None, + 'monthly_power_peak_w': None, + 'smr_version': None, + 'total_energy_export_kwh': 0, + 'total_energy_export_t1_kwh': 0, + 'total_energy_export_t2_kwh': None, + 'total_energy_export_t3_kwh': None, + 'total_energy_export_t4_kwh': None, + 'total_energy_import_kwh': 63.651, + 'total_energy_import_t1_kwh': 63.651, + 'total_energy_import_t2_kwh': None, + 'total_energy_import_t3_kwh': None, + 'total_energy_import_t4_kwh': None, + 'total_gas_m3': None, + 'total_liter_m3': None, + 'unique_meter_id': None, + 'voltage_sag_l1_count': None, + 'voltage_sag_l2_count': None, + 'voltage_sag_l3_count': None, + 'voltage_swell_l1_count': None, + 'voltage_swell_l2_count': None, + 'voltage_swell_l3_count': None, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength': 94, + }), + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '3.03', + 'product_name': 'Energy Socket', 'product_type': 'HWE-SKT', 'serial': '**REDACTED**', }), @@ -66,6 +135,214 @@ }), 'entry': dict({ 'ip_address': '**REDACTED**', + 'product_name': 'Product name', + 'product_type': 'product_type', + 'serial': '**REDACTED**', + }), + }) +# --- +# name: test_diagnostics[HWE-WTR] + dict({ + 'data': dict({ + 'data': dict({ + 'active_current_l1_a': None, + 'active_current_l2_a': None, + 'active_current_l3_a': None, + 'active_frequency_hz': None, + 'active_liter_lpm': 0, + 'active_power_average_w': None, + 'active_power_l1_w': None, + 'active_power_l2_w': None, + 'active_power_l3_w': None, + 'active_power_w': None, + 'active_tariff': None, + 'active_voltage_l1_v': None, + 'active_voltage_l2_v': None, + 'active_voltage_l3_v': None, + 'any_power_fail_count': None, + 'external_devices': None, + 'gas_timestamp': None, + 'gas_unique_id': None, + 'long_power_fail_count': None, + 'meter_model': None, + 'monthly_power_peak_timestamp': None, + 'monthly_power_peak_w': None, + 'smr_version': None, + 'total_energy_export_kwh': None, + 'total_energy_export_t1_kwh': None, + 'total_energy_export_t2_kwh': None, + 'total_energy_export_t3_kwh': None, + 'total_energy_export_t4_kwh': None, + 'total_energy_import_kwh': None, + 'total_energy_import_t1_kwh': None, + 'total_energy_import_t2_kwh': None, + 'total_energy_import_t3_kwh': None, + 'total_energy_import_t4_kwh': None, + 'total_gas_m3': None, + 'total_liter_m3': 17.014, + 'unique_meter_id': None, + 'voltage_sag_l1_count': None, + 'voltage_sag_l2_count': None, + 'voltage_sag_l3_count': None, + 'voltage_swell_l1_count': None, + 'voltage_swell_l2_count': None, + 'voltage_swell_l3_count': None, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength': 84, + }), + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '2.03', + 'product_name': 'Watermeter', + 'product_type': 'HWE-WTR', + 'serial': '**REDACTED**', + }), + 'state': None, + 'system': None, + }), + 'entry': dict({ + 'ip_address': '**REDACTED**', + 'product_name': 'Product name', + 'product_type': 'product_type', + 'serial': '**REDACTED**', + }), + }) +# --- +# name: test_diagnostics[SDM230] + dict({ + 'data': dict({ + 'data': dict({ + 'active_current_l1_a': None, + 'active_current_l2_a': None, + 'active_current_l3_a': None, + 'active_frequency_hz': None, + 'active_liter_lpm': None, + 'active_power_average_w': None, + 'active_power_l1_w': -1058.296, + 'active_power_l2_w': None, + 'active_power_l3_w': None, + 'active_power_w': -1058.296, + 'active_tariff': None, + 'active_voltage_l1_v': None, + 'active_voltage_l2_v': None, + 'active_voltage_l3_v': None, + 'any_power_fail_count': None, + 'external_devices': None, + 'gas_timestamp': None, + 'gas_unique_id': None, + 'long_power_fail_count': None, + 'meter_model': None, + 'monthly_power_peak_timestamp': None, + 'monthly_power_peak_w': None, + 'smr_version': None, + 'total_energy_export_kwh': 255.551, + 'total_energy_export_t1_kwh': 255.551, + 'total_energy_export_t2_kwh': None, + 'total_energy_export_t3_kwh': None, + 'total_energy_export_t4_kwh': None, + 'total_energy_import_kwh': 2.705, + 'total_energy_import_t1_kwh': 2.705, + 'total_energy_import_t2_kwh': None, + 'total_energy_import_t3_kwh': None, + 'total_energy_import_t4_kwh': None, + 'total_gas_m3': None, + 'total_liter_m3': None, + 'unique_meter_id': None, + 'voltage_sag_l1_count': None, + 'voltage_sag_l2_count': None, + 'voltage_sag_l3_count': None, + 'voltage_swell_l1_count': None, + 'voltage_swell_l2_count': None, + 'voltage_swell_l3_count': None, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength': 92, + }), + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '3.06', + 'product_name': 'kWh meter', + 'product_type': 'SDM230-wifi', + 'serial': '**REDACTED**', + }), + 'state': None, + 'system': dict({ + 'cloud_enabled': True, + }), + }), + 'entry': dict({ + 'ip_address': '**REDACTED**', + 'product_name': 'Product name', + 'product_type': 'product_type', + 'serial': '**REDACTED**', + }), + }) +# --- +# name: test_diagnostics[SDM630] + dict({ + 'data': dict({ + 'data': dict({ + 'active_current_l1_a': None, + 'active_current_l2_a': None, + 'active_current_l3_a': None, + 'active_frequency_hz': None, + 'active_liter_lpm': None, + 'active_power_average_w': None, + 'active_power_l1_w': -1058.296, + 'active_power_l2_w': 158.102, + 'active_power_l3_w': 0.0, + 'active_power_w': -900.194, + 'active_tariff': None, + 'active_voltage_l1_v': None, + 'active_voltage_l2_v': None, + 'active_voltage_l3_v': None, + 'any_power_fail_count': None, + 'external_devices': None, + 'gas_timestamp': None, + 'gas_unique_id': None, + 'long_power_fail_count': None, + 'meter_model': None, + 'monthly_power_peak_timestamp': None, + 'monthly_power_peak_w': None, + 'smr_version': None, + 'total_energy_export_kwh': 0.523, + 'total_energy_export_t1_kwh': 0.523, + 'total_energy_export_t2_kwh': None, + 'total_energy_export_t3_kwh': None, + 'total_energy_export_t4_kwh': None, + 'total_energy_import_kwh': 0.101, + 'total_energy_import_t1_kwh': 0.101, + 'total_energy_import_t2_kwh': None, + 'total_energy_import_t3_kwh': None, + 'total_energy_import_t4_kwh': None, + 'total_gas_m3': None, + 'total_liter_m3': None, + 'unique_meter_id': None, + 'voltage_sag_l1_count': None, + 'voltage_sag_l2_count': None, + 'voltage_sag_l3_count': None, + 'voltage_swell_l1_count': None, + 'voltage_swell_l2_count': None, + 'voltage_swell_l3_count': None, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength': 92, + }), + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '3.06', + 'product_name': 'KWh meter 3-phase', + 'product_type': 'SDM630-wifi', + 'serial': '**REDACTED**', + }), + 'state': None, + 'system': dict({ + 'cloud_enabled': True, + }), + }), + 'entry': dict({ + 'ip_address': '**REDACTED**', + 'product_name': 'Product name', + 'product_type': 'product_type', + 'serial': '**REDACTED**', }), }) # --- diff --git a/tests/components/homewizard/snapshots/test_number.ambr b/tests/components/homewizard/snapshots/test_number.ambr new file mode 100644 index 00000000000..436abc70ac1 --- /dev/null +++ b/tests/components/homewizard/snapshots/test_number.ambr @@ -0,0 +1,87 @@ +# serializer version: 1 +# name: test_number_entities[HWE-SKT] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Status light brightness', + 'icon': 'mdi:lightbulb-on', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.device_status_light_brightness', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_number_entities[HWE-SKT].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.device_status_light_brightness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:lightbulb-on', + 'original_name': 'Status light brightness', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_light_brightness', + 'unique_id': 'aabbccddeeff_status_light_brightness', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number_entities[HWE-SKT].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-SKT', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.03', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..e237edee58e --- /dev/null +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -0,0 +1,7870 @@ +# serializer version: 1 +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_average_demand:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_average_demand:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_average_demand', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active average demand', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_average_w', + 'unique_id': 'aabbccddeeff_active_power_average_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_average_demand:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active average demand', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_average_demand', + 'last_changed': , + 'last_updated': , + 'state': '123.0', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_current_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_current_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_current_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active current phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_l1_a', + 'unique_id': 'aabbccddeeff_active_current_l1_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_current_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Active current phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_current_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '-4', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_current_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_current_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_current_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active current phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_l2_a', + 'unique_id': 'aabbccddeeff_active_current_l2_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_current_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Active current phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_current_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_current_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_current_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_current_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active current phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_l3_a', + 'unique_id': 'aabbccddeeff_active_current_l3_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_current_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Active current phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_current_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_frequency:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_frequency:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active frequency', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_frequency_hz', + 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Active frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_frequency', + 'last_changed': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_w', + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power', + 'last_changed': , + 'last_updated': , + 'state': '-123', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_power_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_l1_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '-123', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_power_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_power_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_l2_w', + 'unique_id': 'aabbccddeeff_active_power_l2_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '456', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_power_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_power_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_l3_w', + 'unique_id': 'aabbccddeeff_active_power_l3_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '123.456', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_tariff:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_tariff:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + '3', + '4', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_tariff', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:calendar-clock', + 'original_name': 'Active tariff', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_tariff', + 'unique_id': 'aabbccddeeff_active_tariff', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_tariff:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Device Active tariff', + 'icon': 'mdi:calendar-clock', + 'options': list([ + '1', + '2', + '3', + '4', + ]), + }), + 'context': , + 'entity_id': 'sensor.device_active_tariff', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_voltage_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active voltage phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_l1_v', + 'unique_id': 'aabbccddeeff_active_voltage_l1_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Active voltage phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_voltage_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '230.111', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_voltage_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active voltage phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_l2_v', + 'unique_id': 'aabbccddeeff_active_voltage_l2_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Active voltage phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_voltage_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '230.222', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_voltage_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active voltage phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_l3_v', + 'unique_id': 'aabbccddeeff_active_voltage_l3_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_voltage_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Active voltage phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_voltage_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '230.333', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'Active water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_liter_lpm', + 'unique_id': 'aabbccddeeff_active_liter_lpm', + 'unit_of_measurement': 'l/min', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Active water usage', + 'icon': 'mdi:water', + 'state_class': , + 'unit_of_measurement': 'l/min', + }), + 'context': , + 'entity_id': 'sensor.device_active_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '12.345', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_dsmr_version:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_dsmr_version:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_dsmr_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:counter', + 'original_name': 'DSMR version', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dsmr_version', + 'unique_id': 'aabbccddeeff_smr_version', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_dsmr_version:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device DSMR version', + 'icon': 'mdi:counter', + }), + 'context': , + 'entity_id': 'sensor.device_dsmr_version', + 'last_changed': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_gas_meter_identifier:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_gas_meter_identifier:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_gas_meter_identifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': 'Gas meter identifier', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gas_unique_id', + 'unique_id': 'aabbccddeeff_gas_unique_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_gas_meter_identifier:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Gas meter identifier', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.device_gas_meter_identifier', + 'last_changed': , + 'last_updated': , + 'state': '01FFEEDDCCBBAA99887766554433221100', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_long_power_failures_detected:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_long_power_failures_detected:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_long_power_failures_detected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:transmission-tower-off', + 'original_name': 'Long power failures detected', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'long_power_fail_count', + 'unique_id': 'aabbccddeeff_long_power_fail_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_long_power_failures_detected:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Long power failures detected', + 'icon': 'mdi:transmission-tower-off', + }), + 'context': , + 'entity_id': 'sensor.device_long_power_failures_detected', + 'last_changed': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_peak_demand_current_month:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_peak_demand_current_month:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_peak_demand_current_month', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Peak demand current month', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monthly_power_peak_w', + 'unique_id': 'aabbccddeeff_monthly_power_peak_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_peak_demand_current_month:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Peak demand current month', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_peak_demand_current_month', + 'last_changed': , + 'last_updated': , + 'state': '1111.0', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_failures_detected:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_failures_detected:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_power_failures_detected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:transmission-tower-off', + 'original_name': 'Power failures detected', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'any_power_fail_count', + 'unique_id': 'aabbccddeeff_any_power_fail_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_failures_detected:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Power failures detected', + 'icon': 'mdi:transmission-tower-off', + }), + 'context': , + 'entity_id': 'sensor.device_power_failures_detected', + 'last_changed': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_smart_meter_identifier:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_smart_meter_identifier:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_smart_meter_identifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': 'Smart meter identifier', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'unique_meter_id', + 'unique_id': 'aabbccddeeff_unique_meter_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_smart_meter_identifier:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Smart meter identifier', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.device_smart_meter_identifier', + 'last_changed': , + 'last_updated': , + 'state': '00112233445566778899AABBCCDDEEFF', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_smart_meter_model:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_smart_meter_model:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_smart_meter_model', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:gauge', + 'original_name': 'Smart meter model', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_model', + 'unique_id': 'aabbccddeeff_meter_model', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_smart_meter_model:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Smart meter model', + 'icon': 'mdi:gauge', + }), + 'context': , + 'entity_id': 'sensor.device_smart_meter_model', + 'last_changed': , + 'last_updated': , + 'state': 'ISKRA 2M550T-101', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '13086.777', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_t1_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '4321.333', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export_tariff_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export tariff 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_t2_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t2_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export tariff 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export_tariff_2', + 'last_changed': , + 'last_updated': , + 'state': '8765.444', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export_tariff_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export tariff 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_t3_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t3_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export tariff 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export_tariff_3', + 'last_changed': , + 'last_updated': , + 'state': '8765.444', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_4:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_4:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export_tariff_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export tariff 4', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_t4_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t4_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export_tariff_4:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export tariff 4', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export_tariff_4', + 'last_changed': , + 'last_updated': , + 'state': '8765.444', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '13779.338', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_t1_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '10830.511', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import_tariff_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import tariff 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_t2_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t2_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import tariff 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import_tariff_2', + 'last_changed': , + 'last_updated': , + 'state': '2948.827', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import_tariff_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import tariff 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_t3_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t3_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import tariff 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import_tariff_3', + 'last_changed': , + 'last_updated': , + 'state': '2948.827', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_4:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_4:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import_tariff_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import tariff 4', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_t4_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t4_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_import_tariff_4:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import tariff 4', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import_tariff_4', + 'last_changed': , + 'last_updated': , + 'state': '2948.827', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_gas:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_gas:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_gas', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total gas', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_gas_m3', + 'unique_id': 'aabbccddeeff_total_gas_m3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_gas:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'gas', + 'friendly_name': 'Device Total gas', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_gas', + 'last_changed': , + 'last_updated': , + 'state': '1122.333', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:gauge', + 'original_name': 'Total water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_liter_m3', + 'unique_id': 'aabbccddeeff_total_liter_m3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Device Total water usage', + 'icon': 'mdi:gauge', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '1234.567', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alert', + 'original_name': 'Voltage sags detected phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_sag_l1_count', + 'unique_id': 'aabbccddeeff_voltage_sag_l1_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage sags detected phase 1', + 'icon': 'mdi:alert', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alert', + 'original_name': 'Voltage sags detected phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_sag_l2_count', + 'unique_id': 'aabbccddeeff_voltage_sag_l2_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage sags detected phase 2', + 'icon': 'mdi:alert', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alert', + 'original_name': 'Voltage sags detected phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_sag_l3_count', + 'unique_id': 'aabbccddeeff_voltage_sag_l3_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage sags detected phase 3', + 'icon': 'mdi:alert', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alert', + 'original_name': 'Voltage swells detected phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_swell_l1_count', + 'unique_id': 'aabbccddeeff_voltage_swell_l1_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage swells detected phase 1', + 'icon': 'mdi:alert', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alert', + 'original_name': 'Voltage swells detected phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_swell_l2_count', + 'unique_id': 'aabbccddeeff_voltage_swell_l2_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage swells detected phase 2', + 'icon': 'mdi:alert', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alert', + 'original_name': 'Voltage swells detected phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_swell_l3_count', + 'unique_id': 'aabbccddeeff_voltage_swell_l3_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage swells detected phase 3', + 'icon': 'mdi:alert', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_wi_fi_ssid:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi SSID', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_ssid', + 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi SSID', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'last_changed': , + 'last_updated': , + 'state': 'My Wi-Fi', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_wi_fi_strength:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_wi_fi_strength:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi strength', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_strength', + 'unique_id': 'aabbccddeeff_wifi_strength', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_wi_fi_strength:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi strength', + 'icon': 'mdi:wifi', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_average_demand:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_average_demand:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_average_demand', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active average demand', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_average_w', + 'unique_id': 'aabbccddeeff_active_power_average_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_average_demand:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active average demand', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_average_demand', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_current_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_current_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_current_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active current phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_l1_a', + 'unique_id': 'aabbccddeeff_active_current_l1_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_current_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Active current phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_current_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_current_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_current_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_current_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active current phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_l2_a', + 'unique_id': 'aabbccddeeff_active_current_l2_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_current_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Active current phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_current_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_current_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_current_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_current_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active current phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_l3_a', + 'unique_id': 'aabbccddeeff_active_current_l3_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_current_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Active current phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_current_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_frequency:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_frequency:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active frequency', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_frequency_hz', + 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Active frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_frequency', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_w', + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_power_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_l1_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_power_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_power_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_l2_w', + 'unique_id': 'aabbccddeeff_active_power_l2_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_power_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_power_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_l3_w', + 'unique_id': 'aabbccddeeff_active_power_l3_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_voltage_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_voltage_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_voltage_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active voltage phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_l1_v', + 'unique_id': 'aabbccddeeff_active_voltage_l1_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_voltage_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Active voltage phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_voltage_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_voltage_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_voltage_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_voltage_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active voltage phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_l2_v', + 'unique_id': 'aabbccddeeff_active_voltage_l2_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_voltage_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Active voltage phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_voltage_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_voltage_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_voltage_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_voltage_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active voltage phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_l3_v', + 'unique_id': 'aabbccddeeff_active_voltage_l3_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_voltage_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Active voltage phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_voltage_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'Active water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_liter_lpm', + 'unique_id': 'aabbccddeeff_active_liter_lpm', + 'unit_of_measurement': 'l/min', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Active water usage', + 'icon': 'mdi:water', + 'state_class': , + 'unit_of_measurement': 'l/min', + }), + 'context': , + 'entity_id': 'sensor.device_active_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_long_power_failures_detected:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_long_power_failures_detected:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_long_power_failures_detected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:transmission-tower-off', + 'original_name': 'Long power failures detected', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'long_power_fail_count', + 'unique_id': 'aabbccddeeff_long_power_fail_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_long_power_failures_detected:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Long power failures detected', + 'icon': 'mdi:transmission-tower-off', + }), + 'context': , + 'entity_id': 'sensor.device_long_power_failures_detected', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_peak_demand_current_month:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_peak_demand_current_month:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_peak_demand_current_month', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Peak demand current month', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monthly_power_peak_w', + 'unique_id': 'aabbccddeeff_monthly_power_peak_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_peak_demand_current_month:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Peak demand current month', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_peak_demand_current_month', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_power_failures_detected:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_power_failures_detected:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_power_failures_detected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:transmission-tower-off', + 'original_name': 'Power failures detected', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'any_power_fail_count', + 'unique_id': 'aabbccddeeff_any_power_fail_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_power_failures_detected:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Power failures detected', + 'icon': 'mdi:transmission-tower-off', + }), + 'context': , + 'entity_id': 'sensor.device_power_failures_detected', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export_tariff_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_t1_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export_tariff_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export_tariff_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export_tariff_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export tariff 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_t2_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t2_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export_tariff_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export tariff 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export_tariff_2', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export_tariff_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export_tariff_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export_tariff_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export tariff 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_t3_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t3_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export_tariff_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export tariff 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export_tariff_3', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export_tariff_4:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export_tariff_4:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export_tariff_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export tariff 4', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_t4_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t4_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export_tariff_4:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export tariff 4', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export_tariff_4', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import_tariff_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_t1_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import_tariff_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import_tariff_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import_tariff_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import tariff 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_t2_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t2_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import_tariff_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import tariff 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import_tariff_2', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import_tariff_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import_tariff_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import_tariff_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import tariff 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_t3_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t3_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import_tariff_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import tariff 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import_tariff_3', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import_tariff_4:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import_tariff_4:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import_tariff_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import tariff 4', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_t4_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t4_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_import_tariff_4:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import tariff 4', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import_tariff_4', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_gas:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_gas:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_gas', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total gas', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_gas_m3', + 'unique_id': 'aabbccddeeff_total_gas_m3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_gas:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'gas', + 'friendly_name': 'Device Total gas', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_gas', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:gauge', + 'original_name': 'Total water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_liter_m3', + 'unique_id': 'aabbccddeeff_total_liter_m3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Device Total water usage', + 'icon': 'mdi:gauge', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_sags_detected_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_sags_detected_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alert', + 'original_name': 'Voltage sags detected phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_sag_l1_count', + 'unique_id': 'aabbccddeeff_voltage_sag_l1_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_sags_detected_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage sags detected phase 1', + 'icon': 'mdi:alert', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_sags_detected_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_sags_detected_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alert', + 'original_name': 'Voltage sags detected phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_sag_l2_count', + 'unique_id': 'aabbccddeeff_voltage_sag_l2_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_sags_detected_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage sags detected phase 2', + 'icon': 'mdi:alert', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_sags_detected_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_sags_detected_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alert', + 'original_name': 'Voltage sags detected phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_sag_l3_count', + 'unique_id': 'aabbccddeeff_voltage_sag_l3_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_sags_detected_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage sags detected phase 3', + 'icon': 'mdi:alert', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_swells_detected_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_swells_detected_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alert', + 'original_name': 'Voltage swells detected phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_swell_l1_count', + 'unique_id': 'aabbccddeeff_voltage_swell_l1_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_swells_detected_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage swells detected phase 1', + 'icon': 'mdi:alert', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_swells_detected_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_swells_detected_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alert', + 'original_name': 'Voltage swells detected phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_swell_l2_count', + 'unique_id': 'aabbccddeeff_voltage_swell_l2_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_swells_detected_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage swells detected phase 2', + 'icon': 'mdi:alert', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_swells_detected_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_swells_detected_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alert', + 'original_name': 'Voltage swells detected phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_swell_l3_count', + 'unique_id': 'aabbccddeeff_voltage_swell_l3_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_swells_detected_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage swells detected phase 3', + 'icon': 'mdi:alert', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_active_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-SKT', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_active_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_w', + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_active_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power', + 'last_changed': , + 'last_updated': , + 'state': '1457.277', + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_active_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-SKT', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_active_power_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_l1_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_active_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '1457.277', + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-SKT', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-SKT', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '63.651', + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_wi_fi_ssid:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-SKT', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi SSID', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_ssid', + 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi SSID', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'last_changed': , + 'last_updated': , + 'state': 'My Wi-Fi', + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_wi_fi_strength:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-SKT', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_wi_fi_strength:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi strength', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_strength', + 'unique_id': 'aabbccddeeff_wifi_strength', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_wi_fi_strength:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi strength', + 'icon': 'mdi:wifi', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'last_changed': , + 'last_updated': , + 'state': '94', + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_active_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-WTR', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_active_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'Active water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_liter_lpm', + 'unique_id': 'aabbccddeeff_active_liter_lpm', + 'unit_of_measurement': 'l/min', + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_active_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Active water usage', + 'icon': 'mdi:water', + 'state_class': , + 'unit_of_measurement': 'l/min', + }), + 'context': , + 'entity_id': 'sensor.device_active_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_total_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-WTR', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_total_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:gauge', + 'original_name': 'Total water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_liter_m3', + 'unique_id': 'aabbccddeeff_total_liter_m3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_total_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Device Total water usage', + 'icon': 'mdi:gauge', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '17.014', + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_wi_fi_ssid:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-WTR', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi SSID', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_ssid', + 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi SSID', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'last_changed': , + 'last_updated': , + 'state': 'My Wi-Fi', + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_wi_fi_strength:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-WTR', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_wi_fi_strength:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi strength', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_strength', + 'unique_id': 'aabbccddeeff_wifi_strength', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_wi_fi_strength:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi strength', + 'icon': 'mdi:wifi', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'last_changed': , + 'last_updated': , + 'state': '84', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_w', + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power', + 'last_changed': , + 'last_updated': , + 'state': '-1058.296', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_power_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_l1_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '-1058.296', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '255.551', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '2.705', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_wi_fi_ssid:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi SSID', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_ssid', + 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi SSID', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'last_changed': , + 'last_updated': , + 'state': 'My Wi-Fi', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_wi_fi_strength:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_wi_fi_strength:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi strength', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_strength', + 'unique_id': 'aabbccddeeff_wifi_strength', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_wi_fi_strength:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi strength', + 'icon': 'mdi:wifi', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'last_changed': , + 'last_updated': , + 'state': '92', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_w', + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power', + 'last_changed': , + 'last_updated': , + 'state': '-900.194', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_l1_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '-1058.296', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_l2_w', + 'unique_id': 'aabbccddeeff_active_power_l2_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '158.102', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_l3_w', + 'unique_id': 'aabbccddeeff_active_power_l3_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Active power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '0.523', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Total energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '0.101', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_wi_fi_ssid:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi SSID', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_ssid', + 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi SSID', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'last_changed': , + 'last_updated': , + 'state': 'My Wi-Fi', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_wi_fi_strength:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_wi_fi_strength:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi strength', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_strength', + 'unique_id': 'aabbccddeeff_wifi_strength', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_wi_fi_strength:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi strength', + 'icon': 'mdi:wifi', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'last_changed': , + 'last_updated': , + 'state': '92', + }) +# --- diff --git a/tests/components/homewizard/snapshots/test_switch.ambr b/tests/components/homewizard/snapshots/test_switch.ambr new file mode 100644 index 00000000000..0fb4680a0b1 --- /dev/null +++ b/tests/components/homewizard/snapshots/test_switch.ambr @@ -0,0 +1,381 @@ +# serializer version: 1 +# name: test_switch_entities[HWE-SKT-switch.device-state_set-power_on] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Device', + }), + 'context': , + 'entity_id': 'switch.device', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_entities[HWE-SKT-switch.device-state_set-power_on].1 + 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.device', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_power_on', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[HWE-SKT-switch.device-state_set-power_on].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-SKT', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.03', + 'via_device_id': None, + }) +# --- +# name: test_switch_entities[HWE-SKT-switch.device_cloud_connection-system_set-cloud_enabled] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Cloud connection', + 'icon': 'mdi:cloud', + }), + 'context': , + 'entity_id': 'switch.device_cloud_connection', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_entities[HWE-SKT-switch.device_cloud_connection-system_set-cloud_enabled].1 + 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.device_cloud_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cloud', + 'original_name': 'Cloud connection', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_connection', + 'unique_id': 'aabbccddeeff_cloud_connection', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[HWE-SKT-switch.device_cloud_connection-system_set-cloud_enabled].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-SKT', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.03', + 'via_device_id': None, + }) +# --- +# name: test_switch_entities[HWE-SKT-switch.device_switch_lock-state_set-switch_lock] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Switch lock', + 'icon': 'mdi:lock-open', + }), + 'context': , + 'entity_id': 'switch.device_switch_lock', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_entities[HWE-SKT-switch.device_switch_lock-state_set-switch_lock].1 + 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.device_switch_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:lock-open', + 'original_name': 'Switch lock', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'switch_lock', + 'unique_id': 'aabbccddeeff_switch_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[HWE-SKT-switch.device_switch_lock-state_set-switch_lock].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-SKT', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.03', + 'via_device_id': None, + }) +# --- +# name: test_switch_entities[SDM230-switch.device_cloud_connection-system_set-cloud_enabled] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Cloud connection', + 'icon': 'mdi:cloud', + }), + 'context': , + 'entity_id': 'switch.device_cloud_connection', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_entities[SDM230-switch.device_cloud_connection-system_set-cloud_enabled].1 + 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.device_cloud_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cloud', + 'original_name': 'Cloud connection', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_connection', + 'unique_id': 'aabbccddeeff_cloud_connection', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[SDM230-switch.device_cloud_connection-system_set-cloud_enabled].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_switch_entities[SDM630-switch.device_cloud_connection-system_set-cloud_enabled] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Cloud connection', + 'icon': 'mdi:cloud', + }), + 'context': , + 'entity_id': 'switch.device_cloud_connection', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_entities[SDM630-switch.device_cloud_connection-system_set-cloud_enabled].1 + 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.device_cloud_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cloud', + 'original_name': 'Cloud connection', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_connection', + 'unique_id': 'aabbccddeeff_cloud_connection', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[SDM630-switch.device_cloud_connection-system_set-cloud_enabled].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/homewizard/test_button.py b/tests/components/homewizard/test_button.py index d8b8b5030b6..c25a4ed0f4e 100644 --- a/tests/components/homewizard/test_button.py +++ b/tests/components/homewizard/test_button.py @@ -1,174 +1,92 @@ """Test the identify button for HomeWizard.""" -from unittest.mock import patch +from unittest.mock import MagicMock from homewizard_energy.errors import DisabledError, RequestError import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import button -from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er -from .generator import get_mock_device +pytestmark = [ + pytest.mark.usefixtures("init_integration"), + pytest.mark.freeze_time("2021-01-01 12:00:00"), +] +@pytest.mark.parametrize("device_fixture", ["HWE-WTR", "SDM230", "SDM630"]) async def test_identify_button_entity_not_loaded_when_not_available( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry + hass: HomeAssistant, ) -> None: """Does not load button when device has no support for it.""" - - api = get_mock_device(product_type="SDM230-WIFI") - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert hass.states.get("button.product_name_aabbccddeeff_identify") is None + assert not hass.states.get("button.device_identify") -async def test_identify_button_is_loaded( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry +async def test_identify_button( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_homewizardenergy: MagicMock, + snapshot: SnapshotAssertion, ) -> None: """Loads button when device has support.""" + assert (state := hass.states.get("button.device_identify")) + assert snapshot == state - api = get_mock_device(product_type="HWE-SKT", firmware_version="3.02") + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot == entity_entry - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert snapshot == device_entry - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("button.product_name_aabbccddeeff_identify") - assert state - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Identify" - ) - - entity_registry = er.async_get(hass) - entry = entity_registry.async_get("button.product_name_aabbccddeeff_identify") - assert entry - assert entry.unique_id == "aabbccddeeff_identify" - - -async def test_identify_press( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test button press is handled correctly.""" - - api = get_mock_device(product_type="HWE-SKT", firmware_version="3.02") - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert ( - hass.states.get("button.product_name_aabbccddeeff_identify").state - == STATE_UNKNOWN - ) - - assert api.identify.call_count == 0 + assert len(mock_homewizardenergy.identify.mock_calls) == 0 await hass.services.async_call( button.DOMAIN, button.SERVICE_PRESS, - {"entity_id": "button.product_name_aabbccddeeff_identify"}, + {ATTR_ENTITY_ID: state.entity_id}, blocking=True, ) - assert api.identify.call_count == 1 + assert len(mock_homewizardenergy.identify.mock_calls) == 1 - -async def test_identify_press_catches_requesterror( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test button press is handled RequestError correctly.""" - - api = get_mock_device(product_type="HWE-SKT", firmware_version="3.02") - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert ( - hass.states.get("button.product_name_aabbccddeeff_identify").state - == STATE_UNKNOWN - ) + assert (state := hass.states.get(state.entity_id)) + assert state.state == "2021-01-01T12:00:00+00:00" # Raise RequestError when identify is called - api.identify.side_effect = RequestError() + mock_homewizardenergy.identify.side_effect = RequestError() - assert api.identify.call_count == 0 - - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match=r"^An error occurred while communicating with HomeWizard device$", + ): await hass.services.async_call( button.DOMAIN, button.SERVICE_PRESS, - {"entity_id": "button.product_name_aabbccddeeff_identify"}, + {ATTR_ENTITY_ID: state.entity_id}, blocking=True, ) - assert api.identify.call_count == 1 + assert len(mock_homewizardenergy.identify.mock_calls) == 2 - -async def test_identify_press_catches_disablederror( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test button press is handled DisabledError correctly.""" - - api = get_mock_device(product_type="HWE-SKT", firmware_version="3.02") - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert ( - hass.states.get("button.product_name_aabbccddeeff_identify").state - == STATE_UNKNOWN - ) + assert (state := hass.states.get(state.entity_id)) + assert state.state == "2021-01-01T12:00:00+00:00" # Raise RequestError when identify is called - api.identify.side_effect = DisabledError() + mock_homewizardenergy.identify.side_effect = DisabledError() - assert api.identify.call_count == 0 - - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match=r"^The local API of the HomeWizard device is disabled$", + ): await hass.services.async_call( button.DOMAIN, button.SERVICE_PRESS, - {"entity_id": "button.product_name_aabbccddeeff_identify"}, + {ATTR_ENTITY_ID: state.entity_id}, blocking=True, ) - assert api.identify.call_count == 1 + + assert len(mock_homewizardenergy.identify.mock_calls) == 3 + assert (state := hass.states.get(state.entity_id)) + assert state.state == "2021-01-01T12:00:00+00:00" diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index 770496b5612..5e71826b28d 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -1,8 +1,10 @@ """Test the homewizard config flow.""" from ipaddress import ip_address -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError +import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries from homeassistant.components import zeroconf @@ -11,371 +13,308 @@ from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .generator import get_mock_device - from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.usefixtures("mock_setup_entry") async def test_manual_flow_works( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, ) -> None: """Test config flow accepts user configuration.""" - - device = get_mock_device() - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" - with patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=device, - ), patch( - "homeassistant.components.homewizard.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) - assert result["type"] == "create_entry" - assert result["title"] == "P1 meter" - assert result["data"][CONF_IP_ADDRESS] == "2.2.2.2" + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result == snapshot assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - assert len(device.close.mock_calls) == len(device.device.mock_calls) - + assert len(mock_homewizardenergy.close.mock_calls) == 1 + assert len(mock_homewizardenergy.device.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_homewizardenergy", "mock_setup_entry") async def test_discovery_flow_works( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + snapshot: SnapshotAssertion, ) -> None: """Test discovery setup flow works.""" - - service_info = zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("192.168.43.183"), - ip_addresses=[ip_address("192.168.43.183")], - port=80, - hostname="p1meter-ddeeff.local.", - type="", - name="", - properties={ - "api_enabled": "1", - "path": "/api/v1", - "product_name": "Energy Socket", - "product_type": "HWE-SKT", - "serial": "aabbccddeeff", - }, + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="p1meter-ddeeff.local.", + type="", + name="", + properties={ + "api_enabled": "1", + "path": "/api/v1", + "product_name": "Energy Socket", + "product_type": "HWE-SKT", + "serial": "aabbccddeeff", + }, + ), ) - with patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=get_mock_device(), - ): - flow = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=service_info, - ) - - with patch( - "homeassistant.components.homewizard.async_setup_entry", - return_value=True, - ), patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=get_mock_device(), - ): - result = await hass.config_entries.flow.async_configure( - flow["flow_id"], user_input=None - ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "discovery_confirm" - with patch( - "homeassistant.components.homewizard.async_setup_entry", - return_value=True, - ), patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=get_mock_device(), - ): - result = await hass.config_entries.flow.async_configure( - flow["flow_id"], user_input={"ip_address": "192.168.43.183"} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=None + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"ip_address": "127.0.0.1"} + ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Energy Socket" - assert result["data"][CONF_IP_ADDRESS] == "192.168.43.183" - - assert result["result"] - assert result["result"].unique_id == "HWE-SKT_aabbccddeeff" + assert result == snapshot +@pytest.mark.usefixtures("mock_homewizardenergy") async def test_discovery_flow_during_onboarding( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_onboarding: MagicMock + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_onboarding: MagicMock, + snapshot: SnapshotAssertion, ) -> None: """Test discovery setup flow during onboarding.""" - - with patch( - "homeassistant.components.homewizard.async_setup_entry", - return_value=True, - ) as mock_setup_entry, patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=get_mock_device(), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("192.168.43.183"), - ip_addresses=[ip_address("192.168.43.183")], - port=80, - hostname="p1meter-ddeeff.local.", - type="mock_type", - name="mock_name", - properties={ - "api_enabled": "1", - "path": "/api/v1", - "product_name": "P1 meter", - "product_type": "HWE-P1", - "serial": "aabbccddeeff", - }, - ), - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="p1meter-ddeeff.local.", + type="mock_type", + name="mock_name", + properties={ + "api_enabled": "1", + "path": "/api/v1", + "product_name": "P1 meter", + "product_type": "HWE-P1", + "serial": "aabbccddeeff", + }, + ), + ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "P1 meter" - assert result["data"][CONF_IP_ADDRESS] == "192.168.43.183" - - assert result["result"] - assert result["result"].unique_id == "HWE-P1_aabbccddeeff" + assert result == snapshot assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_onboarding.mock_calls) == 1 async def test_discovery_flow_during_onboarding_disabled_api( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_onboarding: MagicMock + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + mock_setup_entry: AsyncMock, + mock_onboarding: MagicMock, + snapshot: SnapshotAssertion, ) -> None: """Test discovery setup flow during onboarding with a disabled API.""" + mock_homewizardenergy.device.side_effect = DisabledError - def mock_initialize(): - raise DisabledError - - device = get_mock_device() - device.device.side_effect = mock_initialize - - with patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=device, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("192.168.43.183"), - ip_addresses=[ip_address("192.168.43.183")], - port=80, - hostname="p1meter-ddeeff.local.", - type="mock_type", - name="mock_name", - properties={ - "api_enabled": "0", - "path": "/api/v1", - "product_name": "P1 meter", - "product_type": "HWE-P1", - "serial": "aabbccddeeff", - }, - ), - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="p1meter-ddeeff.local.", + type="mock_type", + name="mock_name", + properties={ + "api_enabled": "0", + "path": "/api/v1", + "product_name": "P1 meter", + "product_type": "HWE-P1", + "serial": "aabbccddeeff", + }, + ), + ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "discovery_confirm" assert result["errors"] == {"base": "api_not_enabled"} # We are onboarded, user enabled API again and picks up from discovery/config flow - device.device.side_effect = None + mock_homewizardenergy.device.side_effect = None mock_onboarding.return_value = True - with patch( - "homeassistant.components.homewizard.async_setup_entry", - return_value=True, - ) as mock_setup_entry, patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=device, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"ip_address": "192.168.43.183"} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"ip_address": "127.0.0.1"} + ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "P1 meter" - assert result["data"][CONF_IP_ADDRESS] == "192.168.43.183" - - assert result["result"] - assert result["result"].unique_id == "HWE-P1_aabbccddeeff" + assert result == snapshot assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_onboarding.mock_calls) == 1 async def test_discovery_disabled_api( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, ) -> None: """Test discovery detecting disabled api.""" - - service_info = zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("192.168.43.183"), - ip_addresses=[ip_address("192.168.43.183")], - port=80, - hostname="p1meter-ddeeff.local.", - type="", - name="", - properties={ - "api_enabled": "0", - "path": "/api/v1", - "product_name": "P1 meter", - "product_type": "HWE-P1", - "serial": "aabbccddeeff", - }, - ) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=service_info, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="p1meter-ddeeff.local.", + type="", + name="", + properties={ + "api_enabled": "0", + "path": "/api/v1", + "product_name": "P1 meter", + "product_type": "HWE-P1", + "serial": "aabbccddeeff", + }, + ), ) assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" - def mock_initialize(): - raise DisabledError + mock_homewizardenergy.device.side_effect = DisabledError - device = get_mock_device() - device.device.side_effect = mock_initialize - - with patch( - "homeassistant.components.homewizard.async_setup_entry", - return_value=True, - ), patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=device, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"ip_address": "192.168.43.183"} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"ip_address": "127.0.0.1"} + ) assert result["type"] == FlowResultType.FORM assert result["errors"] == {"base": "api_not_enabled"} -async def test_discovery_missing_data_in_service_info( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_discovery_missing_data_in_service_info(hass: HomeAssistant) -> None: """Test discovery detecting missing discovery info.""" - - service_info = zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("192.168.43.183"), - ip_addresses=[ip_address("192.168.43.183")], - port=80, - hostname="p1meter-ddeeff.local.", - type="", - name="", - properties={ - # "api_enabled": "1", --> removed - "path": "/api/v1", - "product_name": "P1 meter", - "product_type": "HWE-P1", - "serial": "aabbccddeeff", - }, - ) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=service_info, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="p1meter-ddeeff.local.", + type="", + name="", + properties={ + # "api_enabled": "1", --> removed + "path": "/api/v1", + "product_name": "P1 meter", + "product_type": "HWE-P1", + "serial": "aabbccddeeff", + }, + ), ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "invalid_discovery_parameters" -async def test_discovery_invalid_api( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_discovery_invalid_api(hass: HomeAssistant) -> None: """Test discovery detecting invalid_api.""" - - service_info = zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("192.168.43.183"), - ip_addresses=[ip_address("192.168.43.183")], - port=80, - hostname="p1meter-ddeeff.local.", - type="", - name="", - properties={ - "api_enabled": "1", - "path": "/api/not_v1", - "product_name": "P1 meter", - "product_type": "HWE-P1", - "serial": "aabbccddeeff", - }, - ) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=service_info, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="p1meter-ddeeff.local.", + type="", + name="", + properties={ + "api_enabled": "1", + "path": "/api/not_v1", + "product_name": "P1 meter", + "product_type": "HWE-P1", + "serial": "aabbccddeeff", + }, + ), ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "unsupported_api_version" -async def test_check_disabled_api( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("exception", "reason"), + [(DisabledError, "api_not_enabled"), (RequestError, "network_error")], +) +async def test_error_flow( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + exception: Exception, + reason: str, ) -> None: """Test check detecting disabled api.""" - - def mock_initialize(): - raise DisabledError - - device = get_mock_device() - device.device.side_effect = mock_initialize + mock_homewizardenergy.device.side_effect = exception result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" - with patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=device, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "127.0.0.1"} + ) assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": "api_not_enabled"} + assert result["errors"] == {"base": reason} + + # Recover from error + mock_homewizardenergy.device.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "127.0.0.1"} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY -async def test_check_error_handling_api( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +@pytest.mark.parametrize( + ("exception", "reason"), + [ + (Exception, "unknown_error"), + (UnsupportedError, "unsupported_api_version"), + ], +) +async def test_abort_flow( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + exception: Exception, + reason: str, ) -> None: """Test check detecting error with api.""" - - def mock_initialize(): - raise Exception() - - device = get_mock_device() - device.device.side_effect = mock_initialize + mock_homewizardenergy.device.side_effect = exception result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -384,146 +323,60 @@ async def test_check_error_handling_api( assert result["type"] == "form" assert result["step_id"] == "user" - with patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=device, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "unknown_error" - - -async def test_check_detects_invalid_api( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test check detecting device endpoint failed fetching data.""" - - def mock_initialize(): - raise UnsupportedError - - device = get_mock_device() - device.device.side_effect = mock_initialize - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == "form" - assert result["step_id"] == "user" - - with patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=device, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} - ) - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "unsupported_api_version" - - -async def test_check_requesterror( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test check detecting device endpoint failed fetching data due to a requesterror.""" - - def mock_initialize(): - raise RequestError - - device = get_mock_device() - device.device.side_effect = mock_initialize - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == "form" - assert result["step_id"] == "user" - - with patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=device, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} - ) - - assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": "network_error"} + assert result["reason"] == reason +@pytest.mark.usefixtures("mock_homewizardenergy", "mock_setup_entry") async def test_reauth_flow( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, ) -> None: """Test reauth flow while API is enabled.""" - - mock_entry = MockConfigEntry( - domain="homewizard_energy", data={CONF_IP_ADDRESS: "1.2.3.4"} - ) - - mock_entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={ "source": config_entries.SOURCE_REAUTH, - "entry_id": mock_entry.entry_id, + "entry_id": mock_config_entry.entry_id, }, ) assert result["type"] == "form" assert result["step_id"] == "reauth_confirm" - device = get_mock_device() - with patch( - "homeassistant.components.homewizard.async_setup_entry", - return_value=True, - ), patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=device, - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "reauth_successful" + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" async def test_reauth_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test reauth flow while API is still disabled.""" - - def mock_initialize(): - raise DisabledError() - - mock_entry = MockConfigEntry( - domain="homewizard_energy", data={CONF_IP_ADDRESS: "1.2.3.4"} - ) - - mock_entry.add_to_hass(hass) + mock_homewizardenergy.device.side_effect = DisabledError + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={ "source": config_entries.SOURCE_REAUTH, - "entry_id": mock_entry.entry_id, + "entry_id": mock_config_entry.entry_id, }, ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - device = get_mock_device() - device.device.side_effect = mock_initialize - with patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", - return_value=device, - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": "api_not_enabled"} + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "api_not_enabled"} diff --git a/tests/components/homewizard/test_diagnostics.py b/tests/components/homewizard/test_diagnostics.py index 9e9797439b3..5a140fa70c8 100644 --- a/tests/components/homewizard/test_diagnostics.py +++ b/tests/components/homewizard/test_diagnostics.py @@ -1,5 +1,6 @@ """Tests for diagnostics data.""" +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -9,6 +10,16 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +@pytest.mark.parametrize( + "device_fixture", + [ + "HWE-P1", + "HWE-SKT", + "HWE-WTR", + "SDM230", + "SDM630", + ], +) async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index 52f214952ab..7dab8cfbb06 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -1,106 +1,62 @@ """Tests for the homewizard component.""" from asyncio import TimeoutError -from unittest.mock import patch +from unittest.mock import MagicMock from homewizard_energy.errors import DisabledError, HomeWizardEnergyException +import pytest from homeassistant.components.homewizard.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant -from .generator import get_mock_device - from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker async def test_load_unload( - aioclient_mock: AiohttpClientMocker, hass: HomeAssistant + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homewizardenergy: MagicMock, ) -> None: """Test loading and unloading of integration.""" - - device = get_mock_device() - - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_IP_ADDRESS: "1.1.1.1"}, - unique_id=DOMAIN, - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=device, - ): - await hass.config_entries.async_setup(entry.entry_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() - assert entry.state is ConfigEntryState.LOADED + assert mock_config_entry.state is ConfigEntryState.LOADED + assert len(mock_homewizardenergy.device.mock_calls) == 1 - await hass.config_entries.async_unload(entry.entry_id) + await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert entry.state is ConfigEntryState.NOT_LOADED + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED async def test_load_failed_host_unavailable( - aioclient_mock: AiohttpClientMocker, hass: HomeAssistant + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homewizardenergy: MagicMock, ) -> None: """Test setup handles unreachable host.""" - - def MockInitialize(): - raise TimeoutError() - - device = get_mock_device() - device.device.side_effect = MockInitialize - - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_IP_ADDRESS: "1.1.1.1"}, - unique_id=DOMAIN, - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=device, - ): - await hass.config_entries.async_setup(entry.entry_id) - + mock_homewizardenergy.device.side_effect = TimeoutError() + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert entry.state is ConfigEntryState.SETUP_RETRY + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY async def test_load_detect_api_disabled( - aioclient_mock: AiohttpClientMocker, hass: HomeAssistant + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homewizardenergy: MagicMock, ) -> None: """Test setup detects disabled API.""" - - def MockInitialize(): - raise DisabledError() - - device = get_mock_device() - device.device.side_effect = MockInitialize - - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_IP_ADDRESS: "1.1.1.1"}, - unique_id=DOMAIN, - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=device, - ): - await hass.config_entries.async_setup(entry.entry_id) - + mock_homewizardenergy.device.side_effect = DisabledError() + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert entry.state is ConfigEntryState.SETUP_RETRY + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -111,125 +67,54 @@ async def test_load_detect_api_disabled( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH - assert flow["context"].get("entry_id") == entry.entry_id + assert flow["context"].get("entry_id") == mock_config_entry.entry_id +@pytest.mark.usefixtures("mock_homewizardenergy") async def test_load_removes_reauth_flow( - aioclient_mock: AiohttpClientMocker, hass: HomeAssistant + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, ) -> None: """Test setup removes reauth flow when API is enabled.""" - - device = get_mock_device() - - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_IP_ADDRESS: "1.1.1.1"}, - unique_id=DOMAIN, - ) - entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) # Add reauth flow from 'previously' failed init - entry.async_start_reauth(hass) + mock_config_entry.async_start_reauth(hass) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) assert len(flows) == 1 - # Initialize entry - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=device, - ): - await hass.config_entries.async_setup(entry.entry_id) - + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert entry.state is ConfigEntryState.LOADED + assert mock_config_entry.state is ConfigEntryState.LOADED # Flow should be removed flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) assert len(flows) == 0 +@pytest.mark.parametrize( + "exception", + [ + HomeWizardEnergyException, + Exception, + ], +) async def test_load_handles_homewizardenergy_exception( - aioclient_mock: AiohttpClientMocker, hass: HomeAssistant + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homewizardenergy: MagicMock, + exception: Exception, ) -> None: """Test setup handles exception from API.""" - - def MockInitialize(): - raise HomeWizardEnergyException() - - device = get_mock_device() - device.device.side_effect = MockInitialize - - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_IP_ADDRESS: "1.1.1.1"}, - unique_id=DOMAIN, - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=device, - ): - await hass.config_entries.async_setup(entry.entry_id) - + mock_homewizardenergy.device.side_effect = exception + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert entry.state is ConfigEntryState.SETUP_RETRY or ConfigEntryState.SETUP_ERROR - - -async def test_load_handles_generic_exception( - aioclient_mock: AiohttpClientMocker, hass: HomeAssistant -) -> None: - """Test setup handles global exception.""" - - def MockInitialize(): - raise Exception() - - device = get_mock_device() - device.device.side_effect = MockInitialize - - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_IP_ADDRESS: "1.1.1.1"}, - unique_id=DOMAIN, + assert mock_config_entry.state in ( + ConfigEntryState.SETUP_RETRY, + ConfigEntryState.SETUP_ERROR, ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=device, - ): - await hass.config_entries.async_setup(entry.entry_id) - - await hass.async_block_till_done() - - assert entry.state is ConfigEntryState.SETUP_RETRY or ConfigEntryState.SETUP_ERROR - - -async def test_load_handles_initialization_error( - aioclient_mock: AiohttpClientMocker, hass: HomeAssistant -) -> None: - """Test handles non-exception error.""" - - device = get_mock_device() - device.device = None - - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_IP_ADDRESS: "1.1.1.1"}, - unique_id=DOMAIN, - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=device, - ): - await hass.config_entries.async_setup(entry.entry_id) - - await hass.async_block_till_done() - - assert entry.state is ConfigEntryState.SETUP_RETRY or ConfigEntryState.SETUP_ERROR diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index aa4ab01cfc6..ebd8d80ece2 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -1,275 +1,103 @@ -"""Test the update coordinator for HomeWizard.""" -from unittest.mock import AsyncMock, patch +"""Test the number entity for HomeWizard.""" +from unittest.mock import MagicMock from homewizard_energy.errors import DisabledError, RequestError -from homewizard_energy.models import State import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import number +from homeassistant.components.homewizard.const import UPDATE_INTERVAL from homeassistant.components.number import ATTR_VALUE, SERVICE_SET_VALUE -from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er +import homeassistant.util.dt as dt_util -from .generator import get_mock_device +from tests.common import async_fire_time_changed + +pytestmark = [ + pytest.mark.usefixtures("init_integration"), +] -async def test_number_entity_not_loaded_when_not_available( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry +@pytest.mark.parametrize("device_fixture", ["HWE-SKT"]) +async def test_number_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_homewizardenergy: MagicMock, + snapshot: SnapshotAssertion, ) -> None: - """Test entity does not load number when brightness is not available.""" + """Test number handles state changes correctly.""" + assert (state := hass.states.get("number.device_status_light_brightness")) + assert snapshot == state - api = get_mock_device() + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot == entity_entry - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert snapshot == device_entry - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert ( - hass.states.get("number.product_name_aabbccddeeff_status_light_brightness") - is None - ) - - -async def test_number_loads_entities( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity does load number when brightness is available.""" - - api = get_mock_device(product_type="HWE-SKT") - api.state = AsyncMock(return_value=State.from_dict({"brightness": 255})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("number.product_name_aabbccddeeff_status_light_brightness") - assert state + # Test unknown handling assert state.state == "100" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Status light brightness" + + mock_homewizardenergy.state.return_value.brightness = None + + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + await hass.async_block_till_done() + + assert (state := hass.states.get(state.entity_id)) + assert state.state == STATE_UNKNOWN + + # Test service methods + assert len(mock_homewizardenergy.state_set.mock_calls) == 0 + await hass.services.async_call( + number.DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: state.entity_id, + ATTR_VALUE: 50, + }, + blocking=True, ) - entry = entity_registry.async_get( - "number.product_name_aabbccddeeff_status_light_brightness" - ) - assert entry - assert entry.unique_id == "aabbccddeeff_status_light_brightness" - assert not entry.disabled + assert len(mock_homewizardenergy.state_set.mock_calls) == 1 + mock_homewizardenergy.state_set.assert_called_with(brightness=127) - -async def test_brightness_level_set( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity turns sets light level.""" - - api = get_mock_device(product_type="HWE-SKT") - api.state = AsyncMock(return_value=State.from_dict({"brightness": 255})) - - def state_set(brightness): - api.state = AsyncMock(return_value=State.from_dict({"brightness": brightness})) - - api.state_set = AsyncMock(side_effect=state_set) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, + mock_homewizardenergy.state_set.side_effect = RequestError + with pytest.raises( + HomeAssistantError, + match=r"^An error occurred while communicating with HomeWizard device$", ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert ( - hass.states.get( - "number.product_name_aabbccddeeff_status_light_brightness" - ).state - == "100" - ) - - # Set level halfway await hass.services.async_call( number.DOMAIN, SERVICE_SET_VALUE, { - ATTR_ENTITY_ID: ( - "number.product_name_aabbccddeeff_status_light_brightness" - ), + ATTR_ENTITY_ID: state.entity_id, ATTR_VALUE: 50, }, blocking=True, ) - await hass.async_block_till_done() - assert ( - hass.states.get( - "number.product_name_aabbccddeeff_status_light_brightness" - ).state - == "50" - ) - assert len(api.state_set.mock_calls) == 1 - - # Turn off level + mock_homewizardenergy.state_set.side_effect = DisabledError + with pytest.raises( + HomeAssistantError, + match=r"^The local API of the HomeWizard device is disabled$", + ): await hass.services.async_call( number.DOMAIN, SERVICE_SET_VALUE, { - ATTR_ENTITY_ID: ( - "number.product_name_aabbccddeeff_status_light_brightness" - ), - ATTR_VALUE: 0, + ATTR_ENTITY_ID: state.entity_id, + ATTR_VALUE: 50, }, blocking=True, ) - await hass.async_block_till_done() - assert ( - hass.states.get( - "number.product_name_aabbccddeeff_status_light_brightness" - ).state - == "0" - ) - assert len(api.state_set.mock_calls) == 2 - -async def test_brightness_level_set_catches_requesterror( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity raises HomeAssistantError when RequestError was raised.""" - - api = get_mock_device(product_type="HWE-SKT") - api.state = AsyncMock(return_value=State.from_dict({"brightness": 255})) - - api.state_set = AsyncMock(side_effect=RequestError()) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - # Set level halfway - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - number.DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: ( - "number.product_name_aabbccddeeff_status_light_brightness" - ), - ATTR_VALUE: 50, - }, - blocking=True, - ) - - -async def test_brightness_level_set_catches_disablederror( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity raises HomeAssistantError when DisabledError was raised.""" - - api = get_mock_device(product_type="HWE-SKT") - api.state = AsyncMock(return_value=State.from_dict({"brightness": 255})) - - api.state_set = AsyncMock(side_effect=DisabledError()) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - # Set level halfway - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - number.DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: ( - "number.product_name_aabbccddeeff_status_light_brightness" - ), - ATTR_VALUE: 50, - }, - blocking=True, - ) - - -async def test_brightness_level_set_catches_invalid_value( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity raises ValueError when value was invalid.""" - - api = get_mock_device(product_type="HWE-SKT") - api.state = AsyncMock(return_value=State.from_dict({"brightness": 255})) - - def state_set(brightness): - api.state = AsyncMock(return_value=State.from_dict({"brightness": brightness})) - - api.state_set = AsyncMock(side_effect=state_set) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - with pytest.raises(ValueError): - await hass.services.async_call( - number.DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: ( - "number.product_name_aabbccddeeff_status_light_brightness" - ), - ATTR_VALUE: -1, - }, - blocking=True, - ) - - with pytest.raises(ValueError): - await hass.services.async_call( - number.DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: ( - "number.product_name_aabbccddeeff_status_light_brightness" - ), - ATTR_VALUE: 101, - }, - blocking=True, - ) +@pytest.mark.parametrize("device_fixture", ["HWE-WTR", "SDM230", "SDM630"]) +async def test_entities_not_created_for_device(hass: HomeAssistant) -> None: + """Does not load button when device has no support for it.""" + assert not hass.states.get("number.device_status_light_brightness") diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index 7ad5140b815..7e59769a768 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -1,1761 +1,430 @@ -"""Test the update coordinator for HomeWizard.""" -from datetime import timedelta -from unittest.mock import AsyncMock, patch +"""Test sensor entity for HomeWizard.""" + +from unittest.mock import MagicMock from homewizard_energy.errors import DisabledError, RequestError -from homewizard_energy.models import Data +import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.sensor import ( - ATTR_OPTIONS, - ATTR_STATE_CLASS, - SensorDeviceClass, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_FRIENDLY_NAME, - ATTR_ICON, - ATTR_UNIT_OF_MEASUREMENT, - UnitOfElectricCurrent, - UnitOfElectricPotential, - UnitOfEnergy, - UnitOfFrequency, - UnitOfPower, - UnitOfVolume, -) +from homeassistant.components.homewizard.const import UPDATE_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.util.dt as dt_util -from .generator import get_mock_device - from tests.common import async_fire_time_changed +pytestmark = [ + pytest.mark.usefixtures("init_integration"), +] -async def test_sensor_entity_smr_version( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "HWE-P1", + [ + "sensor.device_dsmr_version", + "sensor.device_smart_meter_model", + "sensor.device_smart_meter_identifier", + "sensor.device_wi_fi_ssid", + "sensor.device_active_tariff", + "sensor.device_wi_fi_strength", + "sensor.device_total_energy_import", + "sensor.device_total_energy_import_tariff_1", + "sensor.device_total_energy_import_tariff_2", + "sensor.device_total_energy_import_tariff_3", + "sensor.device_total_energy_import_tariff_4", + "sensor.device_total_energy_export", + "sensor.device_total_energy_export_tariff_1", + "sensor.device_total_energy_export_tariff_2", + "sensor.device_total_energy_export_tariff_3", + "sensor.device_total_energy_export_tariff_4", + "sensor.device_active_power", + "sensor.device_active_power_phase_1", + "sensor.device_active_power_phase_2", + "sensor.device_active_power_phase_3", + "sensor.device_active_voltage_phase_1", + "sensor.device_active_voltage_phase_2", + "sensor.device_active_voltage_phase_3", + "sensor.device_active_current_phase_1", + "sensor.device_active_current_phase_2", + "sensor.device_active_current_phase_3", + "sensor.device_active_frequency", + "sensor.device_voltage_sags_detected_phase_1", + "sensor.device_voltage_sags_detected_phase_2", + "sensor.device_voltage_sags_detected_phase_3", + "sensor.device_voltage_swells_detected_phase_1", + "sensor.device_voltage_swells_detected_phase_2", + "sensor.device_voltage_swells_detected_phase_3", + "sensor.device_power_failures_detected", + "sensor.device_long_power_failures_detected", + "sensor.device_active_average_demand", + "sensor.device_peak_demand_current_month", + "sensor.device_total_gas", + "sensor.device_gas_meter_identifier", + "sensor.device_active_water_usage", + "sensor.device_total_water_usage", + ], + ), + ( + "HWE-P1-zero-values", + [ + "sensor.device_total_energy_import", + "sensor.device_total_energy_import_tariff_1", + "sensor.device_total_energy_import_tariff_2", + "sensor.device_total_energy_import_tariff_3", + "sensor.device_total_energy_import_tariff_4", + "sensor.device_total_energy_export", + "sensor.device_total_energy_export_tariff_1", + "sensor.device_total_energy_export_tariff_2", + "sensor.device_total_energy_export_tariff_3", + "sensor.device_total_energy_export_tariff_4", + "sensor.device_active_power", + "sensor.device_active_power_phase_1", + "sensor.device_active_power_phase_2", + "sensor.device_active_power_phase_3", + "sensor.device_active_voltage_phase_1", + "sensor.device_active_voltage_phase_2", + "sensor.device_active_voltage_phase_3", + "sensor.device_active_current_phase_1", + "sensor.device_active_current_phase_2", + "sensor.device_active_current_phase_3", + "sensor.device_active_frequency", + "sensor.device_voltage_sags_detected_phase_1", + "sensor.device_voltage_sags_detected_phase_2", + "sensor.device_voltage_sags_detected_phase_3", + "sensor.device_voltage_swells_detected_phase_1", + "sensor.device_voltage_swells_detected_phase_2", + "sensor.device_voltage_swells_detected_phase_3", + "sensor.device_power_failures_detected", + "sensor.device_long_power_failures_detected", + "sensor.device_active_average_demand", + "sensor.device_peak_demand_current_month", + "sensor.device_total_gas", + "sensor.device_active_water_usage", + "sensor.device_total_water_usage", + ], + ), + ( + "HWE-SKT", + [ + "sensor.device_wi_fi_ssid", + "sensor.device_wi_fi_strength", + "sensor.device_total_energy_import", + "sensor.device_total_energy_export", + "sensor.device_active_power", + "sensor.device_active_power_phase_1", + ], + ), + ( + "HWE-WTR", + [ + "sensor.device_wi_fi_ssid", + "sensor.device_wi_fi_strength", + "sensor.device_active_water_usage", + "sensor.device_total_water_usage", + ], + ), + ( + "SDM230", + [ + "sensor.device_wi_fi_ssid", + "sensor.device_wi_fi_strength", + "sensor.device_total_energy_import", + "sensor.device_total_energy_export", + "sensor.device_active_power", + "sensor.device_active_power_phase_1", + ], + ), + ( + "SDM630", + [ + "sensor.device_wi_fi_ssid", + "sensor.device_wi_fi_strength", + "sensor.device_total_energy_import", + "sensor.device_total_energy_export", + "sensor.device_active_power", + "sensor.device_active_power_phase_1", + "sensor.device_active_power_phase_2", + "sensor.device_active_power_phase_3", + ], + ), + ], +) +async def test_sensors( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + entity_ids: list[str], ) -> None: - """Test entity loads smr version.""" + """Test that sensor entity snapshots match.""" + for entity_id in entity_ids: + assert (state := hass.states.get(entity_id)) + assert snapshot(name=f"{entity_id}:state") == state - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"smr_version": 50})) + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot(name=f"{entity_id}:entity-registry") == entity_entry - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_dsmr_version") - entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_dsmr_version") - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_smr_version" - assert not entry.disabled - assert state.state == "50" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) DSMR version" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - assert state.attributes.get(ATTR_ICON) == "mdi:counter" + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert snapshot(name=f"{entity_id}:device-registry") == device_entry -async def test_sensor_entity_meter_model( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "HWE-P1", + [ + "sensor.device_wi_fi_strength", + "sensor.device_active_voltage_phase_1", + "sensor.device_active_voltage_phase_2", + "sensor.device_active_voltage_phase_3", + "sensor.device_active_current_phase_1", + "sensor.device_active_current_phase_2", + "sensor.device_active_current_phase_3", + "sensor.device_active_frequency", + ], + ), + ( + "HWE-P1-unused-exports", + [ + "sensor.device_total_energy_export", + "sensor.device_total_energy_export_tariff_1", + "sensor.device_total_energy_export_tariff_2", + "sensor.device_total_energy_export_tariff_3", + "sensor.device_total_energy_export_tariff_4", + ], + ), + ( + "HWE-SKT", + [ + "sensor.device_wi_fi_strength", + ], + ), + ( + "HWE-WTR", + [ + "sensor.device_wi_fi_strength", + ], + ), + ( + "SDM230", + [ + "sensor.device_wi_fi_strength", + ], + ), + ( + "SDM630", + [ + "sensor.device_wi_fi_strength", + ], + ), + ], +) +async def test_disabled_by_default_sensors( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_ids: list[str] ) -> None: - """Test entity loads meter model.""" + """Test the disabled by default sensors.""" + for entity_id in entity_ids: + assert not hass.states.get(entity_id) - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"meter_model": "Model X"})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_smart_meter_model") - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_smart_meter_model" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_meter_model" - assert not entry.disabled - assert state.state == "Model X" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Smart meter model" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - assert state.attributes.get(ATTR_ICON) == "mdi:gauge" - - -async def test_sensor_entity_unique_meter_id( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads unique meter id.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"unique_id": "4E47475955"})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_smart_meter_identifier") - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_smart_meter_identifier" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_unique_meter_id" - assert not entry.disabled - assert state.state == "NGGYU" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Smart meter identifier" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - assert state.attributes.get(ATTR_ICON) == "mdi:alphabetical-variant" - - -async def test_sensor_entity_wifi_ssid( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads wifi ssid.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"wifi_ssid": "My Wifi"})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_wi_fi_ssid") - entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_wi_fi_ssid") - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_wifi_ssid" - assert not entry.disabled - assert state.state == "My Wifi" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Wi-Fi SSID" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - assert state.attributes.get(ATTR_ICON) == "mdi:wifi" - - -async def test_sensor_entity_active_tariff( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active_tariff.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_tariff": 2})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_active_tariff") - entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_active_tariff") - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_active_tariff" - assert not entry.disabled - assert state.state == "2" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active tariff" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert state.attributes.get(ATTR_ICON) == "mdi:calendar-clock" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENUM - assert state.attributes.get(ATTR_OPTIONS) == ["1", "2", "3", "4"] - - -async def test_sensor_entity_wifi_strength( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads wifi strength.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"wifi_strength": 42})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_wi_fi_strength") - assert entry - assert entry.unique_id == "aabbccddeeff_wifi_strength" - assert entry.disabled - - -async def test_sensor_entity_total_power_import_tariff_1_kwh( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads total power import t1.""" - - api = get_mock_device() - api.data = AsyncMock( - return_value=Data.from_dict({"total_power_import_t1_kwh": 1234.123}) - ) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_total_power_import_tariff_1" - ) - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_total_power_import_tariff_1" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_total_power_import_t1_kwh" - assert not entry.disabled - assert state.state == "1234.123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Total power import tariff 1" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_total_power_import_tariff_2_kwh( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads total power import t2.""" - - api = get_mock_device() - api.data = AsyncMock( - return_value=Data.from_dict({"total_power_import_t2_kwh": 1234.123}) - ) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_total_power_import_tariff_2" - ) - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_total_power_import_tariff_2" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_total_power_import_t2_kwh" - assert not entry.disabled - assert state.state == "1234.123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Total power import tariff 2" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_total_power_export_tariff_1_kwh( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads total power export t1.""" - - api = get_mock_device() - api.data = AsyncMock( - return_value=Data.from_dict({"total_power_export_t1_kwh": 1234.123}) - ) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_total_power_export_tariff_1" - ) - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_total_power_export_tariff_1" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_total_power_export_t1_kwh" - assert not entry.disabled - assert state.state == "1234.123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Total power export tariff 1" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_total_power_export_tariff_2_kwh( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads total power export t2.""" - - api = get_mock_device() - api.data = AsyncMock( - return_value=Data.from_dict({"total_power_export_t2_kwh": 1234.123}) - ) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_total_power_export_tariff_2" - ) - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_total_power_export_tariff_2" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_total_power_export_t2_kwh" - assert not entry.disabled - assert state.state == "1234.123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Total power export tariff 2" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_active_power( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active power.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_power_w": 123.123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_active_power") - entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_active_power") - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_active_power_w" - assert not entry.disabled - assert state.state == "123.123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active power" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_active_power_l1( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active power l1.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_power_l1_w": 123.123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_active_power_phase_1") - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_power_phase_1" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_active_power_l1_w" - assert not entry.disabled - assert state.state == "123.123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active power phase 1" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_active_power_l2( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active power l2.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_power_l2_w": 456.456})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_active_power_phase_2") - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_power_phase_2" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_active_power_l2_w" - assert not entry.disabled - assert state.state == "456.456" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active power phase 2" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_active_power_l3( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active power l3.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_power_l3_w": 789.789})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_active_power_phase_3") - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_power_phase_3" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_active_power_l3_w" - assert not entry.disabled - assert state.state == "789.789" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active power phase 3" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_total_gas( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads total gas.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"total_gas_m3": 50})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_total_gas") - entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_total_gas") - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_total_gas_m3" - assert not entry.disabled - assert state.state == "50" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Total gas" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_unique_gas_meter_id( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads unique gas meter id.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"gas_unique_id": "4E47475955"})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_gas_meter_identifier") - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_gas_meter_identifier" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_gas_unique_id" - assert not entry.disabled - assert state.state == "NGGYU" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Gas meter identifier" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - assert state.attributes.get(ATTR_ICON) == "mdi:alphabetical-variant" - - -async def test_sensor_entity_active_voltage_l1( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active voltage l1.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_voltage_l1_v": 230.123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - disabled_entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_voltage_phase_1" - ) - assert disabled_entry - assert disabled_entry.disabled - assert disabled_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - # Enable - entry = entity_registry.async_update_entity( - disabled_entry.entity_id, **{"disabled_by": None} - ) - await hass.async_block_till_done() - assert not entry.disabled - assert entry.unique_id == "aabbccddeeff_active_voltage_l1_v" - - # Let HA reload the integration so state is set - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=30), - ) - await hass.async_block_till_done() - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_active_voltage_phase_1" - ) - assert state - assert state.state == "230.123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active voltage phase 1" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfElectricPotential.VOLT - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLTAGE - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_active_voltage_l2( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active voltage l2.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_voltage_l2_v": 230.123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - disabled_entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_voltage_phase_2" - ) - assert disabled_entry - assert disabled_entry.disabled - assert disabled_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - # Enable - entry = entity_registry.async_update_entity( - disabled_entry.entity_id, **{"disabled_by": None} - ) - await hass.async_block_till_done() - assert not entry.disabled - assert entry.unique_id == "aabbccddeeff_active_voltage_l2_v" - - # Let HA reload the integration so state is set - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=30), - ) - await hass.async_block_till_done() - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_active_voltage_phase_2" - ) - assert state - assert state.state == "230.123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active voltage phase 2" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfElectricPotential.VOLT - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLTAGE - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_active_voltage_l3( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active voltage l3.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_voltage_l3_v": 230.123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - disabled_entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_voltage_phase_3" - ) - assert disabled_entry - assert disabled_entry.disabled - assert disabled_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - # Enable - entry = entity_registry.async_update_entity( - disabled_entry.entity_id, **{"disabled_by": None} - ) - await hass.async_block_till_done() - assert not entry.disabled - assert entry.unique_id == "aabbccddeeff_active_voltage_l3_v" - - # Let HA reload the integration so state is set - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=30), - ) - await hass.async_block_till_done() - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_active_voltage_phase_3" - ) - assert state - assert state.state == "230.123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active voltage phase 3" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfElectricPotential.VOLT - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLTAGE - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_active_current_l1( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active current l1.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_current_l1_a": 12.34})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - disabled_entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_current_phase_1" - ) - assert disabled_entry - assert disabled_entry.disabled - assert disabled_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - # Enable - entry = entity_registry.async_update_entity( - disabled_entry.entity_id, **{"disabled_by": None} - ) - await hass.async_block_till_done() - assert not entry.disabled - assert entry.unique_id == "aabbccddeeff_active_current_l1_a" - - # Let HA reload the integration so state is set - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=30), - ) - await hass.async_block_till_done() - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_active_current_phase_1" - ) - assert state - assert state.state == "12.34" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active current phase 1" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfElectricCurrent.AMPERE - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.CURRENT - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_active_current_l2( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active current l2.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_current_l2_a": 12.34})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - disabled_entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_current_phase_2" - ) - assert disabled_entry - assert disabled_entry.disabled - assert disabled_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - # Enable - entry = entity_registry.async_update_entity( - disabled_entry.entity_id, **{"disabled_by": None} - ) - await hass.async_block_till_done() - assert not entry.disabled - assert entry.unique_id == "aabbccddeeff_active_current_l2_a" - - # Let HA reload the integration so state is set - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=30), - ) - await hass.async_block_till_done() - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_active_current_phase_2" - ) - assert state - assert state.state == "12.34" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active current phase 2" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfElectricCurrent.AMPERE - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.CURRENT - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_active_current_l3( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active current l3.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_current_l3_a": 12.34})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - disabled_entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_current_phase_3" - ) - assert disabled_entry - assert disabled_entry.disabled - assert disabled_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - # Enable - entry = entity_registry.async_update_entity( - disabled_entry.entity_id, **{"disabled_by": None} - ) - await hass.async_block_till_done() - assert not entry.disabled - assert entry.unique_id == "aabbccddeeff_active_current_l3_a" - - # Let HA reload the integration so state is set - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=30), - ) - await hass.async_block_till_done() - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_active_current_phase_3" - ) - assert state - assert state.state == "12.34" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active current phase 3" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfElectricCurrent.AMPERE - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.CURRENT - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_active_frequency( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active frequency.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_frequency_hz": 50.12})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - disabled_entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_frequency" - ) - assert disabled_entry - assert disabled_entry.disabled - assert disabled_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - # Enable - entry = entity_registry.async_update_entity( - disabled_entry.entity_id, **{"disabled_by": None} - ) - await hass.async_block_till_done() - assert not entry.disabled - assert entry.unique_id == "aabbccddeeff_active_frequency_hz" - - # Let HA reload the integration so state is set - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=30), - ) - await hass.async_block_till_done() - - state = hass.states.get("sensor.product_name_aabbccddeeff_active_frequency") - assert state - assert state.state == "50.12" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active frequency" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfFrequency.HERTZ - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.FREQUENCY - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_voltage_sag_count_l1( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads voltage_sag_count_l1.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"voltage_sag_l1_count": 123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_voltage_sags_detected_phase_1" - ) - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_voltage_sags_detected_phase_1" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_voltage_sag_l1_count" - assert not entry.disabled - assert state.state == "123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Voltage sags detected phase 1" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - - -async def test_sensor_entity_voltage_sag_count_l2( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads voltage_sag_count_l2.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"voltage_sag_l2_count": 123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_voltage_sags_detected_phase_2" - ) - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_voltage_sags_detected_phase_2" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_voltage_sag_l2_count" - assert not entry.disabled - assert state.state == "123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Voltage sags detected phase 2" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - - -async def test_sensor_entity_voltage_sag_count_l3( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads voltage_sag_count_l3.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"voltage_sag_l3_count": 123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_voltage_sags_detected_phase_3" - ) - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_voltage_sags_detected_phase_3" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_voltage_sag_l3_count" - assert not entry.disabled - assert state.state == "123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Voltage sags detected phase 3" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - - -async def test_sensor_entity_voltage_swell_count_l1( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads voltage_swell_count_l1.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"voltage_swell_l1_count": 123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_voltage_swells_detected_phase_1" - ) - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_voltage_swells_detected_phase_1" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_voltage_swell_l1_count" - assert not entry.disabled - assert state.state == "123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Voltage swells detected phase 1" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - - -async def test_sensor_entity_voltage_swell_count_l2( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads voltage_swell_count_l2.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"voltage_swell_l2_count": 123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_voltage_swells_detected_phase_2" - ) - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_voltage_swells_detected_phase_2" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_voltage_swell_l2_count" - assert not entry.disabled - assert state.state == "123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Voltage swells detected phase 2" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - - -async def test_sensor_entity_voltage_swell_count_l3( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads voltage_swell_count_l3.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"voltage_swell_l3_count": 123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_voltage_swells_detected_phase_3" - ) - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_voltage_swells_detected_phase_3" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_voltage_swell_l3_count" - assert not entry.disabled - assert state.state == "123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Voltage swells detected phase 3" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - - -async def test_sensor_entity_any_power_fail_count( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads any power fail count.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"any_power_fail_count": 123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_power_failures_detected") - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_power_failures_detected" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_any_power_fail_count" - assert not entry.disabled - assert state.state == "123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Power failures detected" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - - -async def test_sensor_entity_long_power_fail_count( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads long power fail count.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"long_power_fail_count": 123})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_long_power_failures_detected" - ) - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_long_power_failures_detected" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_long_power_fail_count" - assert not entry.disabled - assert state.state == "123" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Long power failures detected" - ) - assert ATTR_STATE_CLASS not in state.attributes - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ATTR_DEVICE_CLASS not in state.attributes - - -async def test_sensor_entity_active_power_average( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active power average.""" - - api = get_mock_device() - api.data = AsyncMock( - return_value=Data.from_dict({"active_power_average_w": 123.456}) - ) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_active_average_demand") - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_average_demand" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_active_power_average_w" - assert not entry.disabled - assert state.state == "123.456" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active average demand" - ) - - assert state.attributes.get(ATTR_STATE_CLASS) is None - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_monthly_power_peak( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads monthly power peak.""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"montly_power_peak_w": 1234.456})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get( - "sensor.product_name_aabbccddeeff_peak_demand_current_month" - ) - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_peak_demand_current_month" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_monthly_power_peak_w" - assert not entry.disabled - assert state.state == "1234.456" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Peak demand current month" - ) - - assert state.attributes.get(ATTR_STATE_CLASS) is None - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER - assert ATTR_ICON not in state.attributes - - -async def test_sensor_entity_active_liters( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads active liters (watermeter).""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"active_liter_lpm": 12.345})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_active_water_usage") - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_water_usage" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_active_liter_lpm" - assert not entry.disabled - assert state.state == "12.345" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Active water usage" - ) - - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "l/min" - assert ATTR_DEVICE_CLASS not in state.attributes - assert state.attributes.get(ATTR_ICON) == "mdi:water" - - -async def test_sensor_entity_total_liters( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity loads total liters (watermeter).""" - - api = get_mock_device() - api.data = AsyncMock(return_value=Data.from_dict({"total_liter_m3": 1234.567})) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state = hass.states.get("sensor.product_name_aabbccddeeff_total_water_usage") - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_total_water_usage" - ) - assert entry - assert state - assert entry.unique_id == "aabbccddeeff_total_liter_m3" - assert not entry.disabled - assert state.state == "1234.567" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Total water usage" - ) - - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER - assert state.attributes.get(ATTR_ICON) == "mdi:gauge" - - -async def test_sensor_entity_disabled_when_null( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test sensor disables data with null by default.""" - - api = get_mock_device() - api.data = AsyncMock( - return_value=Data.from_dict( - {"active_power_l2_w": None, "active_power_l3_w": None, "total_gas_m3": None} - ) - ) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_power_phase_2" - ) - assert entry is None - - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_active_power_phase_3" - ) - assert entry is None - - entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_total_gas") - assert entry is None - - -async def test_sensor_entity_export_disabled_when_unused( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test sensor disables export if value is 0.""" - - api = get_mock_device() - api.data = AsyncMock( - return_value=Data.from_dict( - { - "total_power_export_kwh": 0, - "total_power_export_t1_kwh": 0, - "total_power_export_t2_kwh": 0, - "total_power_export_t3_kwh": 0, - "total_power_export_t4_kwh": 0, - } - ) - ) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_total_power_export" - ) - assert entry - assert entry.disabled - - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_total_power_export_tariff_1" - ) - assert entry - assert entry.disabled - - entry = entity_registry.async_get( - "sensor.product_name_aabbccddeeff_total_power_export_tariff_2" - ) - assert entry - assert entry.disabled + assert (entry := entity_registry.async_get(entity_id)) + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION +@pytest.mark.parametrize("exception", [RequestError, DisabledError]) async def test_sensors_unreachable( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + exception: Exception, ) -> None: - """Test sensor handles api unreachable.""" + """Test sensor handles API unreachable.""" + assert (state := hass.states.get("sensor.device_total_energy_import_tariff_1")) + assert state.state == "10830.511" - api = get_mock_device() - api.data = AsyncMock( - return_value=Data.from_dict({"total_power_import_t1_kwh": 1234.123}) - ) + mock_homewizardenergy.data.side_effect = exception + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + await hass.async_block_till_done() - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - utcnow = dt_util.utcnow() # Time after the integration is setup - - assert ( - hass.states.get( - "sensor.product_name_aabbccddeeff_total_power_import_tariff_1" - ).state - == "1234.123" - ) - - api.data.side_effect = RequestError - async_fire_time_changed(hass, utcnow + timedelta(seconds=5)) - await hass.async_block_till_done() - assert ( - hass.states.get( - "sensor.product_name_aabbccddeeff_total_power_import_tariff_1" - ).state - == "unavailable" - ) - - api.data.side_effect = None - async_fire_time_changed(hass, utcnow + timedelta(seconds=10)) - await hass.async_block_till_done() - assert ( - hass.states.get( - "sensor.product_name_aabbccddeeff_total_power_import_tariff_1" - ).state - == "1234.123" - ) + assert (state := hass.states.get(state.entity_id)) + assert state.state == STATE_UNAVAILABLE -async def test_api_disabled( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "HWE-SKT", + [ + "sensor.device_active_average_demand", + "sensor.device_active_current_phase_1", + "sensor.device_active_current_phase_2", + "sensor.device_active_current_phase_3", + "sensor.device_active_frequency", + "sensor.device_active_power_phase_2", + "sensor.device_active_power_phase_3", + "sensor.device_active_tariff", + "sensor.device_active_voltage_phase_1", + "sensor.device_active_voltage_phase_2", + "sensor.device_active_voltage_phase_3", + "sensor.device_active_water_usage", + "sensor.device_dsmr_version", + "sensor.device_gas_meter_identifier", + "sensor.device_long_power_failures_detected", + "sensor.device_peak_demand_current_month", + "sensor.device_power_failures_detected", + "sensor.device_smart_meter_identifier", + "sensor.device_smart_meter_model", + "sensor.device_total_energy_export_tariff_1", + "sensor.device_total_energy_export_tariff_2", + "sensor.device_total_energy_export_tariff_3", + "sensor.device_total_energy_export_tariff_4", + "sensor.device_total_energy_import_tariff_1", + "sensor.device_total_energy_import_tariff_2", + "sensor.device_total_energy_import_tariff_3", + "sensor.device_total_energy_import_tariff_4", + "sensor.device_total_gas", + "sensor.device_total_water_usage", + "sensor.device_voltage_sags_detected_phase_1", + "sensor.device_voltage_sags_detected_phase_2", + "sensor.device_voltage_sags_detected_phase_3", + "sensor.device_voltage_swells_detected_phase_1", + "sensor.device_voltage_swells_detected_phase_2", + "sensor.device_voltage_swells_detected_phase_3", + ], + ), + ( + "HWE-WTR", + [ + "sensor.device_dsmr_version", + "sensor.device_smart_meter_model", + "sensor.device_smart_meter_identifier", + "sensor.device_active_tariff", + "sensor.device_total_energy_import", + "sensor.device_total_energy_import_tariff_1", + "sensor.device_total_energy_import_tariff_2", + "sensor.device_total_energy_import_tariff_3", + "sensor.device_total_energy_import_tariff_4", + "sensor.device_total_energy_export", + "sensor.device_total_energy_export_tariff_1", + "sensor.device_total_energy_export_tariff_2", + "sensor.device_total_energy_export_tariff_3", + "sensor.device_total_energy_export_tariff_4", + "sensor.device_active_power", + "sensor.device_active_power_phase_1", + "sensor.device_active_power_phase_2", + "sensor.device_active_power_phase_3", + "sensor.device_active_voltage_phase_1", + "sensor.device_active_voltage_phase_2", + "sensor.device_active_voltage_phase_3", + "sensor.device_active_current_phase_1", + "sensor.device_active_current_phase_2", + "sensor.device_active_current_phase_3", + "sensor.device_active_frequency", + "sensor.device_voltage_sags_detected_phase_1", + "sensor.device_voltage_sags_detected_phase_2", + "sensor.device_voltage_sags_detected_phase_3", + "sensor.device_voltage_swells_detected_phase_1", + "sensor.device_voltage_swells_detected_phase_2", + "sensor.device_voltage_swells_detected_phase_3", + "sensor.device_power_failures_detected", + "sensor.device_long_power_failures_detected", + "sensor.device_active_average_demand", + "sensor.device_peak_demand_current_month", + "sensor.device_total_gas", + "sensor.device_gas_meter_identifier", + ], + ), + ( + "SDM230", + [ + "sensor.device_active_average_demand", + "sensor.device_active_current_phase_1", + "sensor.device_active_current_phase_2", + "sensor.device_active_current_phase_3", + "sensor.device_active_frequency", + "sensor.device_active_power_phase_2", + "sensor.device_active_power_phase_3", + "sensor.device_active_tariff", + "sensor.device_active_voltage_phase_1", + "sensor.device_active_voltage_phase_2", + "sensor.device_active_voltage_phase_3", + "sensor.device_active_water_usage", + "sensor.device_dsmr_version", + "sensor.device_gas_meter_identifier", + "sensor.device_long_power_failures_detected", + "sensor.device_peak_demand_current_month", + "sensor.device_power_failures_detected", + "sensor.device_smart_meter_identifier", + "sensor.device_smart_meter_model", + "sensor.device_total_energy_export_tariff_1", + "sensor.device_total_energy_export_tariff_2", + "sensor.device_total_energy_export_tariff_3", + "sensor.device_total_energy_export_tariff_4", + "sensor.device_total_energy_import_tariff_1", + "sensor.device_total_energy_import_tariff_2", + "sensor.device_total_energy_import_tariff_3", + "sensor.device_total_energy_import_tariff_4", + "sensor.device_total_gas", + "sensor.device_total_water_usage", + "sensor.device_voltage_sags_detected_phase_1", + "sensor.device_voltage_sags_detected_phase_2", + "sensor.device_voltage_sags_detected_phase_3", + "sensor.device_voltage_swells_detected_phase_1", + "sensor.device_voltage_swells_detected_phase_2", + "sensor.device_voltage_swells_detected_phase_3", + ], + ), + ( + "SDM630", + [ + "sensor.device_active_average_demand", + "sensor.device_active_current_phase_1", + "sensor.device_active_current_phase_2", + "sensor.device_active_current_phase_3", + "sensor.device_active_frequency", + "sensor.device_active_tariff", + "sensor.device_active_voltage_phase_1", + "sensor.device_active_voltage_phase_2", + "sensor.device_active_voltage_phase_3", + "sensor.device_active_water_usage", + "sensor.device_dsmr_version", + "sensor.device_gas_meter_identifier", + "sensor.device_long_power_failures_detected", + "sensor.device_peak_demand_current_month", + "sensor.device_power_failures_detected", + "sensor.device_smart_meter_identifier", + "sensor.device_smart_meter_model", + "sensor.device_total_energy_export_tariff_1", + "sensor.device_total_energy_export_tariff_2", + "sensor.device_total_energy_export_tariff_3", + "sensor.device_total_energy_export_tariff_4", + "sensor.device_total_energy_import_tariff_1", + "sensor.device_total_energy_import_tariff_2", + "sensor.device_total_energy_import_tariff_3", + "sensor.device_total_energy_import_tariff_4", + "sensor.device_total_gas", + "sensor.device_total_water_usage", + "sensor.device_voltage_sags_detected_phase_1", + "sensor.device_voltage_sags_detected_phase_2", + "sensor.device_voltage_sags_detected_phase_3", + "sensor.device_voltage_swells_detected_phase_1", + "sensor.device_voltage_swells_detected_phase_2", + "sensor.device_voltage_swells_detected_phase_3", + ], + ), + ], +) +async def test_entities_not_created_for_device( + hass: HomeAssistant, + entity_ids: list[str], ) -> None: - """Test sensor handles api unreachable.""" - - api = get_mock_device() - api.data = AsyncMock( - return_value=Data.from_dict({"total_power_import_t1_kwh": 1234.123}) - ) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - utcnow = dt_util.utcnow() # Time after the integration is setup - - assert ( - hass.states.get( - "sensor.product_name_aabbccddeeff_total_power_import_tariff_1" - ).state - == "1234.123" - ) - - api.data.side_effect = DisabledError - async_fire_time_changed(hass, utcnow + timedelta(seconds=5)) - await hass.async_block_till_done() - assert ( - hass.states.get( - "sensor.product_name_aabbccddeeff_total_power_import_tariff_1" - ).state - == "unavailable" - ) + """Ensures entities for a specific device are not created.""" + for entity_id in entity_ids: + assert not hass.states.get(entity_id) diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index 6a2623e964f..2f6e777a3a8 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -1,545 +1,195 @@ -"""Test the update coordinator for HomeWizard.""" -from unittest.mock import AsyncMock, patch +"""Test the switch entity for HomeWizard.""" +from unittest.mock import MagicMock -from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError -from homewizard_energy.models import State, System +from homewizard_energy import UnsupportedError +from homewizard_energy.errors import DisabledError, RequestError import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import switch -from homeassistant.components.switch import SwitchDeviceClass +from homeassistant.components.homewizard.const import UPDATE_INTERVAL from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_FRIENDLY_NAME, - ATTR_ICON, + ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_OFF, - STATE_ON, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er +import homeassistant.util.dt as dt_util -from .generator import get_mock_device +from tests.common import async_fire_time_changed + +pytestmark = [ + pytest.mark.usefixtures("init_integration"), +] -async def test_switch_entity_not_loaded_when_not_available( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "HWE-WTR", + [ + "switch.device", + "switch.device_switch_lock", + "switch.device_cloud_connection", + ], + ), + ( + "SDM230", + [ + "switch.device", + "switch.device_switch_lock", + ], + ), + ( + "SDM630", + [ + "switch.device", + "switch.device_switch_lock", + ], + ), + ], +) +async def test_entities_not_created_for_device( + hass: HomeAssistant, + entity_ids: list[str], ) -> None: - """Test entity loads smr version.""" - - api = get_mock_device() - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - state_power_on = hass.states.get("sensor.product_name_aabbccddeeff") - state_switch_lock = hass.states.get("sensor.product_name_aabbccddeeff_switch_lock") - - assert state_power_on is None - assert state_switch_lock is None + """Ensures entities for a specific device are not created.""" + for entity_id in entity_ids: + assert not hass.states.get(entity_id) -async def test_switch_loads_entities( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry +@pytest.mark.parametrize( + ("device_fixture", "entity_id", "method", "parameter"), + [ + ("HWE-SKT", "switch.device", "state_set", "power_on"), + ("HWE-SKT", "switch.device_switch_lock", "state_set", "switch_lock"), + ("HWE-SKT", "switch.device_cloud_connection", "system_set", "cloud_enabled"), + ("SDM230", "switch.device_cloud_connection", "system_set", "cloud_enabled"), + ("SDM630", "switch.device_cloud_connection", "system_set", "cloud_enabled"), + ], +) +async def test_switch_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_homewizardenergy: MagicMock, + snapshot: SnapshotAssertion, + entity_id: str, + method: str, + parameter: str, ) -> None: - """Test entity loads smr version.""" + """Test that switch handles state changes correctly.""" + assert (state := hass.states.get(entity_id)) + assert snapshot == state - api = get_mock_device(product_type="HWE-SKT") - api.state = AsyncMock( - return_value=State.from_dict({"power_on": False, "switch_lock": False}) + assert (entity_entry := entity_registry.async_get(entity_id)) + assert snapshot == entity_entry + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert snapshot == device_entry + + mocked_method = getattr(mock_homewizardenergy, method) + + # Turn power_on on + await hass.services.async_call( + switch.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, ) - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, + assert len(mocked_method.mock_calls) == 1 + mocked_method.assert_called_with(**{parameter: True}) + + # Turn power_on off + await hass.services.async_call( + switch.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert len(mocked_method.mock_calls) == 2 + mocked_method.assert_called_with(**{parameter: False}) + + # Test request error handling + mocked_method.side_effect = RequestError + + with pytest.raises( + HomeAssistantError, + match=r"^An error occurred while communicating with HomeWizard device$", ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - state_power_on = hass.states.get("switch.product_name_aabbccddeeff") - entry_power_on = entity_registry.async_get("switch.product_name_aabbccddeeff") - assert state_power_on - assert entry_power_on - assert entry_power_on.unique_id == "aabbccddeeff_power_on" - assert not entry_power_on.disabled - assert state_power_on.state == STATE_OFF - assert ( - state_power_on.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff)" - ) - assert state_power_on.attributes.get(ATTR_DEVICE_CLASS) == SwitchDeviceClass.OUTLET - assert ATTR_ICON not in state_power_on.attributes - - state_switch_lock = hass.states.get("switch.product_name_aabbccddeeff_switch_lock") - entry_switch_lock = entity_registry.async_get( - "switch.product_name_aabbccddeeff_switch_lock" - ) - - assert state_switch_lock - assert entry_switch_lock - assert entry_switch_lock.unique_id == "aabbccddeeff_switch_lock" - assert not entry_switch_lock.disabled - assert state_switch_lock.state == STATE_OFF - assert ( - state_switch_lock.attributes.get(ATTR_FRIENDLY_NAME) - == "Product Name (aabbccddeeff) Switch lock" - ) - assert state_switch_lock.attributes.get(ATTR_ICON) == "mdi:lock-open" - assert ATTR_DEVICE_CLASS not in state_switch_lock.attributes - - -async def test_switch_power_on_off( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity turns switch on and off.""" - - api = get_mock_device(product_type="HWE-SKT") - api.state = AsyncMock( - return_value=State.from_dict({"power_on": False, "switch_lock": False}) - ) - - def state_set(power_on): - api.state = AsyncMock( - return_value=State.from_dict({"power_on": power_on, "switch_lock": False}) - ) - - api.state_set = AsyncMock(side_effect=state_set) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert hass.states.get("switch.product_name_aabbccddeeff").state == STATE_OFF - - # Turn power_on on await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_ON, - {"entity_id": "switch.product_name_aabbccddeeff"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - await hass.async_block_till_done() - assert len(api.state_set.mock_calls) == 1 - assert hass.states.get("switch.product_name_aabbccddeeff").state == STATE_ON - - # Turn power_on off + with pytest.raises( + HomeAssistantError, + match=r"^An error occurred while communicating with HomeWizard device$", + ): await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_OFF, - {"entity_id": "switch.product_name_aabbccddeeff"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - await hass.async_block_till_done() - assert hass.states.get("switch.product_name_aabbccddeeff").state == STATE_OFF - assert len(api.state_set.mock_calls) == 2 + # Test disabled error handling + mocked_method.side_effect = DisabledError - -async def test_switch_lock_power_on_off( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity turns switch on and off.""" - - api = get_mock_device(product_type="HWE-SKT") - api.state = AsyncMock( - return_value=State.from_dict({"power_on": False, "switch_lock": False}) - ) - - def state_set(switch_lock): - api.state = AsyncMock( - return_value=State.from_dict({"power_on": True, "switch_lock": switch_lock}) - ) - - api.state_set = AsyncMock(side_effect=state_set) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, + with pytest.raises( + HomeAssistantError, + match=r"^The local API of the HomeWizard device is disabled$", ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert ( - hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state - == STATE_OFF - ) - - # Turn power_on on await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_ON, - {"entity_id": "switch.product_name_aabbccddeeff_switch_lock"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - await hass.async_block_till_done() - assert len(api.state_set.mock_calls) == 1 - assert ( - hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state - == STATE_ON - ) - - # Turn power_on off + with pytest.raises( + HomeAssistantError, + match=r"^The local API of the HomeWizard device is disabled$", + ): await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_OFF, - {"entity_id": "switch.product_name_aabbccddeeff_switch_lock"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - await hass.async_block_till_done() - assert ( - hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state - == STATE_OFF - ) - assert len(api.state_set.mock_calls) == 2 - -async def test_switch_lock_sets_power_on_unavailable( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry +@pytest.mark.parametrize("device_fixture", ["HWE-SKT"]) +@pytest.mark.parametrize("exception", [RequestError, DisabledError, UnsupportedError]) +@pytest.mark.parametrize( + ("entity_id", "method"), + [ + ("switch.device", "state"), + ("switch.device_switch_lock", "state"), + ("switch.device_cloud_connection", "system"), + ], +) +async def test_switch_unreachable( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + exception: Exception, + entity_id: str, + method: str, ) -> None: - """Test entity turns switch on and off.""" + """Test that unreachable devices are marked as unavailable.""" + mocked_method = getattr(mock_homewizardenergy, method) + mocked_method.side_effect = exception + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + await hass.async_block_till_done() - api = get_mock_device(product_type="HWE-SKT") - api.state = AsyncMock( - return_value=State.from_dict({"power_on": True, "switch_lock": False}) - ) - - def state_set(switch_lock): - api.state = AsyncMock( - return_value=State.from_dict({"power_on": True, "switch_lock": switch_lock}) - ) - - api.state_set = AsyncMock(side_effect=state_set) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert hass.states.get("switch.product_name_aabbccddeeff").state == STATE_ON - assert ( - hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state - == STATE_OFF - ) - - # Turn power_on on - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_ON, - {"entity_id": "switch.product_name_aabbccddeeff_switch_lock"}, - blocking=True, - ) - - await hass.async_block_till_done() - assert len(api.state_set.mock_calls) == 1 - assert ( - hass.states.get("switch.product_name_aabbccddeeff").state - == STATE_UNAVAILABLE - ) - assert ( - hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state - == STATE_ON - ) - - # Turn power_on off - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_OFF, - {"entity_id": "switch.product_name_aabbccddeeff_switch_lock"}, - blocking=True, - ) - - await hass.async_block_till_done() - assert hass.states.get("switch.product_name_aabbccddeeff").state == STATE_ON - assert ( - hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state - == STATE_OFF - ) - assert len(api.state_set.mock_calls) == 2 - - -async def test_cloud_connection_on_off( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity turns switch on and off.""" - - api = get_mock_device(product_type="HWE-SKT", firmware_version="3.02") - api.system = AsyncMock(return_value=System.from_dict({"cloud_enabled": False})) - - def system_set(cloud_enabled): - api.system = AsyncMock( - return_value=System.from_dict({"cloud_enabled": cloud_enabled}) - ) - - api.system_set = AsyncMock(side_effect=system_set) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert ( - hass.states.get("switch.product_name_aabbccddeeff_cloud_connection").state - == STATE_OFF - ) - - # Enable cloud - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_ON, - {"entity_id": "switch.product_name_aabbccddeeff_cloud_connection"}, - blocking=True, - ) - - await hass.async_block_till_done() - assert len(api.system_set.mock_calls) == 1 - assert ( - hass.states.get("switch.product_name_aabbccddeeff_cloud_connection").state - == STATE_ON - ) - - # Disable cloud - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_OFF, - {"entity_id": "switch.product_name_aabbccddeeff_cloud_connection"}, - blocking=True, - ) - - await hass.async_block_till_done() - assert ( - hass.states.get("switch.product_name_aabbccddeeff_cloud_connection").state - == STATE_OFF - ) - assert len(api.system_set.mock_calls) == 2 - - -async def test_switch_handles_requesterror( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity raises HomeAssistantError when RequestError was raised.""" - - api = get_mock_device(product_type="HWE-SKT", firmware_version="3.02") - api.state = AsyncMock( - return_value=State.from_dict({"power_on": False, "switch_lock": False}) - ) - api.system = AsyncMock(return_value=System.from_dict({"cloud_enabled": False})) - - api.state_set = AsyncMock(side_effect=RequestError()) - api.system_set = AsyncMock(side_effect=RequestError()) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - # Power on toggle - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_ON, - {"entity_id": "switch.product_name_aabbccddeeff"}, - blocking=True, - ) - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_OFF, - {"entity_id": "switch.product_name_aabbccddeeff_cloud_connection"}, - blocking=True, - ) - - # Switch Lock toggle - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_ON, - {"entity_id": "switch.product_name_aabbccddeeff_switch_lock"}, - blocking=True, - ) - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_OFF, - {"entity_id": "switch.product_name_aabbccddeeff_switch_lock"}, - blocking=True, - ) - - # Disable Cloud toggle - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_ON, - {"entity_id": "switch.product_name_aabbccddeeff_cloud_connection"}, - blocking=True, - ) - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_OFF, - {"entity_id": "switch.product_name_aabbccddeeff_cloud_connection"}, - blocking=True, - ) - - -async def test_switch_handles_disablederror( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity raises HomeAssistantError when Disabled was raised.""" - - api = get_mock_device(product_type="HWE-SKT", firmware_version="3.02") - api.state = AsyncMock( - return_value=State.from_dict({"power_on": False, "switch_lock": False}) - ) - api.system = AsyncMock(return_value=System.from_dict({"cloud_enabled": False})) - - api.state_set = AsyncMock(side_effect=DisabledError()) - api.system_set = AsyncMock(side_effect=DisabledError()) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - # Power on toggle - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_ON, - {"entity_id": "switch.product_name_aabbccddeeff"}, - blocking=True, - ) - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_OFF, - {"entity_id": "switch.product_name_aabbccddeeff_cloud_connection"}, - blocking=True, - ) - - # Switch Lock toggle - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_ON, - {"entity_id": "switch.product_name_aabbccddeeff_switch_lock"}, - blocking=True, - ) - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_OFF, - {"entity_id": "switch.product_name_aabbccddeeff_switch_lock"}, - blocking=True, - ) - - # Disable Cloud toggle - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_ON, - {"entity_id": "switch.product_name_aabbccddeeff_cloud_connection"}, - blocking=True, - ) - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_OFF, - {"entity_id": "switch.product_name_aabbccddeeff_cloud_connection"}, - blocking=True, - ) - - -async def test_switch_handles_unsupportedrrror( - hass: HomeAssistant, mock_config_entry_data, mock_config_entry -) -> None: - """Test entity raises HomeAssistantError when Disabled was raised.""" - - api = get_mock_device(product_type="HWE-SKT", firmware_version="3.02") - api.state = AsyncMock(side_effect=UnsupportedError()) - api.system = AsyncMock(side_effect=UnsupportedError()) - - with patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", - return_value=api, - ): - entry = mock_config_entry - entry.data = mock_config_entry_data - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert ( - hass.states.get("switch.product_name_aabbccddeeff_cloud_connection").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("switch.product_name_aabbccddeeff").state - == STATE_UNAVAILABLE - ) + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 45ce862dba8..9c73e88c3df 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -62,6 +62,7 @@ async def test_no_thermostat_options( async def test_static_attributes( hass: HomeAssistant, + entity_registry: er.EntityRegistry, device: MagicMock, config_entry: MagicMock, snapshot: SnapshotAssertion, @@ -70,7 +71,7 @@ async def test_static_attributes( await init_integration(hass, config_entry) entity_id = f"climate.{device.name}" - entry = er.async_get(hass).async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry state = hass.states.get(entity_id) @@ -1200,7 +1201,10 @@ async def test_async_update_errors( async def test_aux_heat_off_service_call( - hass: HomeAssistant, device: MagicMock, config_entry: MagicMock + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device: MagicMock, + config_entry: MagicMock, ) -> None: """Test aux heat off turns of system when no heat configured.""" device.raw_ui_data["SwitchHeatAllowed"] = False @@ -1210,7 +1214,7 @@ async def test_aux_heat_off_service_call( await init_integration(hass, config_entry) entity_id = f"climate.{device.name}" - entry = er.async_get(hass).async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry state = hass.states.get(entity_id) diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py index 73dda8ed223..695688e77f0 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -124,6 +124,7 @@ async def test_no_devices( async def test_remove_stale_device( hass: HomeAssistant, config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, location: MagicMock, another_device: MagicMock, client: MagicMock, @@ -133,7 +134,6 @@ async def test_remove_stale_device( config_entry.add_to_hass(hass) - device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={("OtherDomain", 7654321)}, @@ -146,7 +146,6 @@ async def test_remove_stale_device( hass.states.async_entity_ids_count() == 6 ) # 2 climate entities; 4 sensor entities - device_registry = dr.async_get(hass) device_entry = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) diff --git a/tests/components/http/__init__.py b/tests/components/http/__init__.py index 238f5c7050a..cd1d5916ab8 100644 --- a/tests/components/http/__init__.py +++ b/tests/components/http/__init__.py @@ -1,34 +1,3 @@ """Tests for the HTTP component.""" -from aiohttp import web - # Relic from the past. Kept here so we can run negative tests. HTTP_HEADER_HA_AUTH = "X-HA-access" - - -def mock_real_ip(app): - """Inject middleware to mock real IP. - - Returns a function to set the real IP. - """ - ip_to_mock = None - - def set_ip_to_mock(value): - nonlocal ip_to_mock - ip_to_mock = value - - @web.middleware - async def mock_real_ip(request, handler): - """Mock Real IP middleware.""" - nonlocal ip_to_mock - - request = request.clone(remote=ip_to_mock) - - return await handler(request) - - async def real_ip_startup(app): - """Startup of real ip.""" - app.middlewares.insert(0, mock_real_ip) - - app.on_startup.append(real_ip_startup) - - return set_ip_to_mock diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 246572e64f8..2f1259c22de 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -35,9 +35,10 @@ from homeassistant.components.http.request_context import ( from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component -from . import HTTP_HEADER_HA_AUTH, mock_real_ip +from . import HTTP_HEADER_HA_AUTH from tests.common import MockUser +from tests.test_util import mock_real_ip from tests.typing import ClientSessionGenerator, WebSocketGenerator API_PASSWORD = "test-password" diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index c5fb56a28fc..e38a9c97071 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -16,6 +16,7 @@ from homeassistant.components.http.ban import ( KEY_BAN_MANAGER, KEY_FAILED_LOGIN_ATTEMPTS, IpBanManager, + process_success_login, setup_bans, ) from homeassistant.components.http.view import request_handler_factory @@ -23,9 +24,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -from . import mock_real_ip - from tests.common import async_get_persistent_notifications +from tests.test_util import mock_real_ip from tests.typing import ClientSessionGenerator SUPERVISOR_IP = "1.2.3.4" @@ -332,9 +332,14 @@ async def test_failed_login_attempts_counter( """Return 200 status code.""" return None, 200 + async def auth_true_handler(request): + """Return 200 status code.""" + process_success_login(request) + return None, 200 + app.router.add_get( "/auth_true", - request_handler_factory(hass, Mock(requires_auth=True), auth_handler), + request_handler_factory(hass, Mock(requires_auth=True), auth_true_handler), ) app.router.add_get( "/auth_false", @@ -377,6 +382,14 @@ async def test_failed_login_attempts_counter( # We no longer support trusted networks. resp = await client.get("/auth_true") assert resp.status == HTTPStatus.OK + assert app[KEY_FAILED_LOGIN_ATTEMPTS][remote_ip] == 0 + + resp = await client.get("/auth_false") + assert resp.status == HTTPStatus.UNAUTHORIZED + assert app[KEY_FAILED_LOGIN_ATTEMPTS][remote_ip] == 1 + + resp = await client.get("/auth_false") + assert resp.status == HTTPStatus.UNAUTHORIZED assert app[KEY_FAILED_LOGIN_ATTEMPTS][remote_ip] == 2 diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 5a5bffe6748..97e39811cd8 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -5,8 +5,7 @@ from http import HTTPStatus from ipaddress import ip_network import logging from pathlib import Path -import time -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import Mock, patch import pytest @@ -21,7 +20,6 @@ from homeassistant.util import dt as dt_util from homeassistant.util.ssl import server_context_intermediate, server_context_modern from tests.common import async_fire_time_changed -from tests.test_util.aiohttp import AiohttpClientMockResponse from tests.typing import ClientSessionGenerator @@ -501,22 +499,3 @@ async def test_logging( response = await client.get("/api/states/logging.entity") assert response.status == HTTPStatus.OK assert "GET /api/states/logging.entity" not in caplog.text - - -async def test_hass_access_logger_at_info_level( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test that logging happens at info level.""" - test_logger = logging.getLogger("test.aiohttp.logger") - logger = http.HomeAssistantAccessLogger(test_logger) - mock_request = MagicMock() - response = AiohttpClientMockResponse( - "POST", "http://127.0.0.1", status=HTTPStatus.OK - ) - setattr(response, "body_length", 42) - logger.log(mock_request, response, time.time()) - assert "42" in caplog.text - caplog.clear() - test_logger.setLevel(logging.WARNING) - logger.log(mock_request, response, time.time()) - assert "42" not in caplog.text diff --git a/tests/components/http/test_static.py b/tests/components/http/test_static.py new file mode 100644 index 00000000000..1d711464966 --- /dev/null +++ b/tests/components/http/test_static.py @@ -0,0 +1,61 @@ +"""The tests for http static files.""" + + +from pathlib import Path + +from aiohttp.test_utils import TestClient +from aiohttp.web_exceptions import HTTPForbidden +import pytest + +from homeassistant.components.http.static import CachingStaticResource, _get_file_path +from homeassistant.core import EVENT_HOMEASSISTANT_START, HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +async def http(hass: HomeAssistant) -> None: + """Ensure http is set up.""" + assert await async_setup_component(hass, "http", {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + +@pytest.fixture +async def mock_http_client(hass: HomeAssistant, aiohttp_client: ClientSessionGenerator): + """Start the Home Assistant HTTP component.""" + return await aiohttp_client(hass.http.app, server_kwargs={"skip_url_asserts": True}) + + +@pytest.mark.parametrize( + ("url", "canonical_url"), + ( + ("//a", "//a"), + ("///a", "///a"), + ("/c:\\a\\b", "/c:%5Ca%5Cb"), + ), +) +async def test_static_path_blocks_anchors( + hass: HomeAssistant, + mock_http_client: TestClient, + tmp_path: Path, + url: str, + canonical_url: str, +) -> None: + """Test static paths block anchors.""" + app = hass.http.app + + resource = CachingStaticResource(url, str(tmp_path)) + assert resource.canonical == canonical_url + app.router.register_resource(resource) + app["allow_configured_cors"](resource) + + resp = await mock_http_client.get(canonical_url, allow_redirects=False) + assert resp.status == 403 + + # Tested directly since aiohttp will block it before + # it gets here but we want to make sure if aiohttp ever + # changes we still block it. + with pytest.raises(HTTPForbidden): + _get_file_path(canonical_url, tmp_path, False) diff --git a/tests/components/huawei_lte/__init__.py b/tests/components/huawei_lte/__init__.py index 79602ecfb44..2d43a5eade1 100644 --- a/tests/components/huawei_lte/__init__.py +++ b/tests/components/huawei_lte/__init__.py @@ -1 +1,23 @@ """Tests for the huawei_lte component.""" + +from unittest.mock import MagicMock + +from huawei_lte_api.enums.cradle import ConnectionStatusEnum + + +def magic_client(multi_basic_settings_value: dict) -> MagicMock: + """Mock huawei_lte.Client.""" + information = MagicMock(return_value={"SerialNumber": "test-serial-number"}) + check_notifications = MagicMock(return_value={"SmsStorageFull": 0}) + status = MagicMock( + return_value={"ConnectionStatus": ConnectionStatusEnum.CONNECTED.value} + ) + multi_basic_settings = MagicMock(return_value=multi_basic_settings_value) + wifi_feature_switch = MagicMock(return_value={"wifi24g_switch_enable": 1}) + device = MagicMock(information=information) + monitoring = MagicMock(check_notifications=check_notifications, status=status) + wlan = MagicMock( + multi_basic_settings=multi_basic_settings, + wifi_feature_switch=wifi_feature_switch, + ) + return MagicMock(device=device, monitoring=monitoring, wlan=wlan) diff --git a/tests/components/huawei_lte/test_button.py b/tests/components/huawei_lte/test_button.py new file mode 100644 index 00000000000..982fba166c3 --- /dev/null +++ b/tests/components/huawei_lte/test_button.py @@ -0,0 +1,76 @@ +"""Tests for the Huawei LTE switches.""" +from unittest.mock import MagicMock, patch + +from huawei_lte_api.enums.device import ControlModeEnum + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.huawei_lte.const import ( + BUTTON_KEY_CLEAR_TRAFFIC_STATISTICS, + BUTTON_KEY_RESTART, + DOMAIN, + SERVICE_SUSPEND_INTEGRATION, +) +from homeassistant.const import ATTR_ENTITY_ID, CONF_URL +from homeassistant.core import HomeAssistant + +from . import magic_client + +from tests.common import MockConfigEntry + +MOCK_CONF_URL = "http://huawei-lte.example.com" + + +@patch("homeassistant.components.huawei_lte.Connection", MagicMock()) +@patch("homeassistant.components.huawei_lte.Client", return_value=magic_client({})) +async def test_clear_traffic_statistics(client, hass: HomeAssistant) -> None: + """Test clear traffic statistics button.""" + huawei_lte = MockConfigEntry(domain=DOMAIN, data={CONF_URL: MOCK_CONF_URL}) + huawei_lte.add_to_hass(hass) + await hass.config_entries.async_setup(huawei_lte.entry_id) + await hass.async_block_till_done() + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: f"button.lte_{BUTTON_KEY_CLEAR_TRAFFIC_STATISTICS}"}, + blocking=True, + ) + await hass.async_block_till_done() + client.return_value.monitoring.set_clear_traffic.assert_called_once() + + client.return_value.monitoring.set_clear_traffic.reset_mock() + await hass.services.async_call( + DOMAIN, + SERVICE_SUSPEND_INTEGRATION, + {CONF_URL: MOCK_CONF_URL}, + blocking=True, + ) + await hass.async_block_till_done() + client.return_value.monitoring.set_clear_traffic.assert_not_called() + + +@patch("homeassistant.components.huawei_lte.Connection", MagicMock()) +@patch("homeassistant.components.huawei_lte.Client", return_value=magic_client({})) +async def test_restart(client, hass: HomeAssistant) -> None: + """Test restart button.""" + huawei_lte = MockConfigEntry(domain=DOMAIN, data={CONF_URL: MOCK_CONF_URL}) + huawei_lte.add_to_hass(hass) + await hass.config_entries.async_setup(huawei_lte.entry_id) + await hass.async_block_till_done() + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: f"button.lte_{BUTTON_KEY_RESTART}"}, + blocking=True, + ) + await hass.async_block_till_done() + client.return_value.device.set_control.assert_called_with(ControlModeEnum.REBOOT) + + client.return_value.device.set_control.reset_mock() + await hass.services.async_call( + DOMAIN, + SERVICE_SUSPEND_INTEGRATION, + {CONF_URL: MOCK_CONF_URL}, + blocking=True, + ) + await hass.async_block_till_done() + client.return_value.device.set_control.assert_not_called() diff --git a/tests/components/huawei_lte/test_config_flow.py b/tests/components/huawei_lte/test_config_flow.py index 13307e43648..e358920b07b 100644 --- a/tests/components/huawei_lte/test_config_flow.py +++ b/tests/components/huawei_lte/test_config_flow.py @@ -1,5 +1,7 @@ """Tests for the Huawei LTE config flow.""" +from typing import Any from unittest.mock import patch +from urllib.parse import urlparse, urlunparse from huawei_lte_api.enums.client import ResponseCodeEnum from huawei_lte_api.enums.user import LoginErrorEnum, LoginStateEnum, PasswordTypeEnum @@ -18,6 +20,7 @@ from homeassistant.const import ( CONF_RECIPIENT, CONF_URL, CONF_USERNAME, + CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant @@ -25,8 +28,9 @@ from tests.common import MockConfigEntry FIXTURE_UNIQUE_ID = "SERIALNUMBER" -FIXTURE_USER_INPUT = { +FIXTURE_USER_INPUT: dict[str, Any] = { CONF_URL: "http://192.168.1.1/", + CONF_VERIFY_SSL: False, CONF_USERNAME: "admin", CONF_PASSWORD: "secret", } @@ -95,34 +99,59 @@ async def test_already_configured( assert result["reason"] == "already_configured" -async def test_connection_error( - hass: HomeAssistant, requests_mock: requests_mock.Mocker -) -> None: - """Test we show user form on connection error.""" - requests_mock.request(ANY, ANY, exc=ConnectionError()) +@pytest.mark.parametrize( + ("exception", "errors", "data_patch"), + ( + (ConnectionError(), {CONF_URL: "unknown"}, {}), + (requests.exceptions.SSLError(), {CONF_URL: "ssl_error_try_plain"}, {}), + ( + requests.exceptions.SSLError(), + {CONF_URL: "ssl_error_try_unverified"}, + {CONF_VERIFY_SSL: True}, + ), + ), +) +async def test_connection_errors( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + exception: Exception, + errors: dict[str, str], + data_patch: dict[str, Any], +): + """Test we show user form on various errors.""" + requests_mock.request(ANY, ANY, exc=exception) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=FIXTURE_USER_INPUT + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=FIXTURE_USER_INPUT | data_patch, ) assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] == {CONF_URL: "unknown"} + assert result["errors"] == errors @pytest.fixture def login_requests_mock(requests_mock): """Set up a requests_mock with base mocks for login tests.""" - requests_mock.request( - ANY, FIXTURE_USER_INPUT[CONF_URL], text='' - ) - requests_mock.request( - ANY, - f"{FIXTURE_USER_INPUT[CONF_URL]}api/user/state-login", - text=( - f"{LoginStateEnum.LOGGED_OUT}" - f"{PasswordTypeEnum.SHA256}" - ), + https_url = urlunparse( + urlparse(FIXTURE_USER_INPUT[CONF_URL])._replace(scheme="https") ) + for url in FIXTURE_USER_INPUT[CONF_URL], https_url: + requests_mock.request(ANY, url, text='') + requests_mock.request( + ANY, + f"{url}api/user/state-login", + text=( + f"{LoginStateEnum.LOGGED_OUT}" + f"{PasswordTypeEnum.SHA256}" + ), + ) + requests_mock.request( + ANY, + f"{url}api/user/logout", + text="OK", + ) return requests_mock @@ -194,11 +223,19 @@ async def test_login_error( assert result["errors"] == errors -async def test_success(hass: HomeAssistant, login_requests_mock) -> None: +@pytest.mark.parametrize("scheme", ("http", "https")) +async def test_success(hass: HomeAssistant, login_requests_mock, scheme: str) -> None: """Test successful flow provides entry creation data.""" + user_input = { + **FIXTURE_USER_INPUT, + CONF_URL: urlunparse( + urlparse(FIXTURE_USER_INPUT[CONF_URL])._replace(scheme=scheme) + ), + } + login_requests_mock.request( ANY, - f"{FIXTURE_USER_INPUT[CONF_URL]}api/user/login", + f"{user_input[CONF_URL]}api/user/login", text="OK", ) with patch("homeassistant.components.huawei_lte.async_setup"), patch( @@ -207,14 +244,14 @@ async def test_success(hass: HomeAssistant, login_requests_mock) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, - data=FIXTURE_USER_INPUT, + data=user_input, ) await hass.async_block_till_done() assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["data"][CONF_URL] == FIXTURE_USER_INPUT[CONF_URL] - assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] - assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] + assert result["data"][CONF_URL] == user_input[CONF_URL] + assert result["data"][CONF_USERNAME] == user_input[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == user_input[CONF_PASSWORD] @pytest.mark.parametrize( @@ -300,8 +337,9 @@ async def test_ssdp( ) for k, v in expected_result.items(): - assert result[k] == v + assert result[k] == v # type: ignore[literal-required] # expected is a subset if result.get("data_schema"): + assert result["data_schema"] is not None assert result["data_schema"]({})[CONF_URL] == url + "/" @@ -355,6 +393,7 @@ async def test_reauth( assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "reauth_confirm" + assert result["data_schema"] is not None assert result["data_schema"]({}) == { CONF_USERNAME: mock_entry_data[CONF_USERNAME], CONF_PASSWORD: mock_entry_data[CONF_PASSWORD], @@ -376,7 +415,7 @@ async def test_reauth( await hass.async_block_till_done() for k, v in expected_result.items(): - assert result[k] == v + assert result[k] == v # type: ignore[literal-required] # expected is a subset for k, v in expected_entry_data.items(): assert entry.data[k] == v diff --git a/tests/components/huawei_lte/test_switches.py b/tests/components/huawei_lte/test_switches.py index dee4def9596..acaffdbd0ba 100644 --- a/tests/components/huawei_lte/test_switches.py +++ b/tests/components/huawei_lte/test_switches.py @@ -1,8 +1,6 @@ """Tests for the Huawei LTE switches.""" from unittest.mock import MagicMock, patch -from huawei_lte_api.enums.cradle import ConnectionStatusEnum - from homeassistant.components.huawei_lte.const import DOMAIN from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, @@ -12,43 +10,26 @@ from homeassistant.components.switch import ( from homeassistant.const import ATTR_ENTITY_ID, CONF_URL, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry + +from . import magic_client from tests.common import MockConfigEntry SWITCH_WIFI_GUEST_NETWORK = "switch.lte_wi_fi_guest_network" -def magic_client(multi_basic_settings_value: dict) -> MagicMock: - """Mock huawei_lte.Client.""" - information = MagicMock(return_value={"SerialNumber": "test-serial-number"}) - check_notifications = MagicMock(return_value={"SmsStorageFull": 0}) - status = MagicMock( - return_value={"ConnectionStatus": ConnectionStatusEnum.CONNECTED.value} - ) - multi_basic_settings = MagicMock(return_value=multi_basic_settings_value) - wifi_feature_switch = MagicMock(return_value={"wifi24g_switch_enable": 1}) - device = MagicMock(information=information) - monitoring = MagicMock(check_notifications=check_notifications, status=status) - wlan = MagicMock( - multi_basic_settings=multi_basic_settings, - wifi_feature_switch=wifi_feature_switch, - ) - return MagicMock(device=device, monitoring=monitoring, wlan=wlan) - - @patch("homeassistant.components.huawei_lte.Connection", MagicMock()) @patch("homeassistant.components.huawei_lte.Client", return_value=magic_client({})) async def test_huawei_lte_wifi_guest_network_config_entry_when_network_is_not_present( client, hass: HomeAssistant, + entity_registry: er.EntityRegistry, ) -> None: """Test switch wifi guest network config entry when network is not present.""" huawei_lte = MockConfigEntry(domain=DOMAIN, data={CONF_URL: "http://huawei-lte"}) huawei_lte.add_to_hass(hass) await hass.config_entries.async_setup(huawei_lte.entry_id) await hass.async_block_till_done() - entity_registry: EntityRegistry = er.async_get(hass) assert not entity_registry.async_is_registered(SWITCH_WIFI_GUEST_NETWORK) @@ -62,13 +43,13 @@ async def test_huawei_lte_wifi_guest_network_config_entry_when_network_is_not_pr async def test_huawei_lte_wifi_guest_network_config_entry_when_network_is_present( client, hass: HomeAssistant, + entity_registry: er.EntityRegistry, ) -> None: """Test switch wifi guest network config entry when network is present.""" huawei_lte = MockConfigEntry(domain=DOMAIN, data={CONF_URL: "http://huawei-lte"}) huawei_lte.add_to_hass(hass) await hass.config_entries.async_setup(huawei_lte.entry_id) await hass.async_block_till_done() - entity_registry: EntityRegistry = er.async_get(hass) assert entity_registry.async_is_registered(SWITCH_WIFI_GUEST_NETWORK) @@ -122,7 +103,9 @@ async def test_turn_off_switch_wifi_guest_network(client, hass: HomeAssistant) - return_value=magic_client({"Ssids": {"Ssid": "str"}}), ) async def test_huawei_lte_wifi_guest_network_config_entry_when_ssid_is_str( - client, hass: HomeAssistant + client, + hass: HomeAssistant, + entity_registry: er.EntityRegistry, ) -> None: """Test switch wifi guest network config entry when ssid is a str. @@ -132,7 +115,6 @@ async def test_huawei_lte_wifi_guest_network_config_entry_when_ssid_is_str( huawei_lte.add_to_hass(hass) await hass.config_entries.async_setup(huawei_lte.entry_id) await hass.async_block_till_done() - entity_registry: EntityRegistry = er.async_get(hass) assert not entity_registry.async_is_registered(SWITCH_WIFI_GUEST_NETWORK) @@ -142,7 +124,9 @@ async def test_huawei_lte_wifi_guest_network_config_entry_when_ssid_is_str( return_value=magic_client({"Ssids": {"Ssid": None}}), ) async def test_huawei_lte_wifi_guest_network_config_entry_when_ssid_is_none( - client, hass: HomeAssistant + client, + hass: HomeAssistant, + entity_registry: er.EntityRegistry, ) -> None: """Test switch wifi guest network config entry when ssid is a None. @@ -152,5 +136,4 @@ async def test_huawei_lte_wifi_guest_network_config_entry_when_ssid_is_none( huawei_lte.add_to_hass(hass) await hass.config_entries.async_setup(huawei_lte.entry_id) await hass.async_block_till_done() - entity_registry: EntityRegistry = er.async_get(hass) assert not entity_registry.async_is_registered(SWITCH_WIFI_GUEST_NETWORK) diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index 29b94b17da1..51e0a7dde7a 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -527,7 +527,10 @@ def _get_schema_default(schema, key_name): raise KeyError(f"{key_name} not found in schema") -async def test_options_flow_v2(hass: HomeAssistant) -> None: +async def test_options_flow_v2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: """Test options config flow for a V2 bridge.""" entry = MockConfigEntry( domain="hue", @@ -536,9 +539,8 @@ async def test_options_flow_v2(hass: HomeAssistant) -> None: ) entry.add_to_hass(hass) - dev_reg = dr.async_get(hass) mock_dev_id = "aabbccddee" - dev_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(const.DOMAIN, mock_dev_id)} ) diff --git a/tests/components/hue/test_light_v1.py b/tests/components/hue/test_light_v1.py index 919f95b6a66..c03e04b633d 100644 --- a/tests/components/hue/test_light_v1.py +++ b/tests/components/hue/test_light_v1.py @@ -275,7 +275,9 @@ async def test_lights_color_mode(hass: HomeAssistant, mock_bridge_v1) -> None: ] -async def test_groups(hass: HomeAssistant, mock_bridge_v1) -> None: +async def test_groups( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_bridge_v1 +) -> None: """Test the update_lights function with some lights.""" mock_bridge_v1.mock_light_responses.append({}) mock_bridge_v1.mock_group_responses.append(GROUP_RESPONSE) @@ -295,9 +297,8 @@ async def test_groups(hass: HomeAssistant, mock_bridge_v1) -> None: assert lamp_2 is not None assert lamp_2.state == "on" - ent_reg = er.async_get(hass) - assert ent_reg.async_get("light.group_1").unique_id == "1" - assert ent_reg.async_get("light.group_2").unique_id == "2" + assert entity_registry.async_get("light.group_1").unique_id == "1" + assert entity_registry.async_get("light.group_2").unique_id == "2" async def test_new_group_discovered(hass: HomeAssistant, mock_bridge_v1) -> None: @@ -764,7 +765,12 @@ def test_hs_color() -> None: assert light.hs_color == color.color_xy_to_hs(0.4, 0.5, LIGHT_GAMUT) -async def test_group_features(hass: HomeAssistant, mock_bridge_v1) -> None: +async def test_group_features( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_bridge_v1, +) -> None: """Test group features.""" color_temp_type = "Color temperature light" extended_color_type = "Extended color light" @@ -949,9 +955,6 @@ async def test_group_features(hass: HomeAssistant, mock_bridge_v1) -> None: assert group_3.attributes["supported_color_modes"] == extended_color_mode assert group_3.attributes["supported_features"] == extended_color_feature - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - entry = entity_registry.async_get("light.hue_lamp_1") device_entry = device_registry.async_get(entry.device_id) assert device_entry.suggested_area is None diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index c32abecbd0b..55b0c194781 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -350,7 +350,10 @@ async def test_light_availability( async def test_grouped_lights( - hass: HomeAssistant, mock_bridge_v2, v2_resources_test_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_bridge_v2, + v2_resources_test_data, ) -> None: """Test if all v2 grouped lights get created with correct features.""" await mock_bridge_v2.api.load_test_data(v2_resources_test_data) @@ -359,8 +362,7 @@ async def test_grouped_lights( # test if entities for hue groups are created and enabled by default for entity_id in ("light.test_zone", "light.test_room"): - ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(entity_id) + entity_entry = entity_registry.async_get(entity_id) assert entity_entry # scene entities should have be assigned to the room/zone device/service diff --git a/tests/components/hue/test_migration.py b/tests/components/hue/test_migration.py index ef51c2a2f89..5ca182d1761 100644 --- a/tests/components/hue/test_migration.py +++ b/tests/components/hue/test_migration.py @@ -44,21 +44,23 @@ async def test_auto_switchover(hass: HomeAssistant) -> None: async def test_light_entity_migration( - hass: HomeAssistant, mock_bridge_v2, mock_config_entry_v2, v2_resources_test_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_bridge_v2, + mock_config_entry_v2, + v2_resources_test_data, ) -> None: """Test if entity schema for lights migrates from v1 to v2.""" config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 config_entry.add_to_hass(hass) - ent_reg = er.async_get(hass) - dev_reg = dr.async_get(hass) - # create device/entity with V1 schema in registry - device = dev_reg.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(hue.DOMAIN, "00:17:88:01:09:aa:bb:65-0b")}, ) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "light", hue.DOMAIN, "00:17:88:01:09:aa:bb:65-0b", @@ -77,30 +79,32 @@ async def test_light_entity_migration( await hue.migration.handle_v2_migration(hass, config_entry) # migrated device should now have the new identifier (guid) instead of old style (mac) - migrated_device = dev_reg.async_get(device.id) + migrated_device = device_registry.async_get(device.id) assert migrated_device is not None assert migrated_device.identifiers == { (hue.DOMAIN, "0b216218-d811-4c95-8c55-bbcda50f9d50") } # the entity should have the new unique_id (guid) - migrated_entity = ent_reg.async_get("light.migrated_light_1") + migrated_entity = entity_registry.async_get("light.migrated_light_1") assert migrated_entity is not None assert migrated_entity.unique_id == "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1" async def test_sensor_entity_migration( - hass: HomeAssistant, mock_bridge_v2, mock_config_entry_v2, v2_resources_test_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_bridge_v2, + mock_config_entry_v2, + v2_resources_test_data, ) -> None: """Test if entity schema for sensors migrates from v1 to v2.""" config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 config_entry.add_to_hass(hass) - ent_reg = er.async_get(hass) - dev_reg = dr.async_get(hass) - # create device with V1 schema in registry for Hue motion sensor device_mac = "00:17:aa:bb:cc:09:ac:c3" - device = dev_reg.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(hue.DOMAIN, device_mac)} ) @@ -114,7 +118,7 @@ async def test_sensor_entity_migration( # create entities with V1 schema in registry for Hue motion sensor for dev_class, platform, _ in sensor_mappings: - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( platform, hue.DOMAIN, f"{device_mac}-{dev_class}", @@ -134,14 +138,14 @@ async def test_sensor_entity_migration( await hue.migration.handle_v2_migration(hass, config_entry) # migrated device should now have the new identifier (guid) instead of old style (mac) - migrated_device = dev_reg.async_get(device.id) + migrated_device = device_registry.async_get(device.id) assert migrated_device is not None assert migrated_device.identifiers == { (hue.DOMAIN, "2330b45d-6079-4c6e-bba6-1b68afb1a0d6") } # the entities should have the correct V2 unique_id (guid) for dev_class, platform, new_id in sensor_mappings: - migrated_entity = ent_reg.async_get( + migrated_entity = entity_registry.async_get( f"{platform}.hue_migrated_{dev_class}_sensor" ) assert migrated_entity is not None @@ -149,16 +153,18 @@ async def test_sensor_entity_migration( async def test_group_entity_migration_with_v1_id( - hass: HomeAssistant, mock_bridge_v2, mock_config_entry_v2, v2_resources_test_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_bridge_v2, + mock_config_entry_v2, + v2_resources_test_data, ) -> None: """Test if entity schema for grouped_lights migrates from v1 to v2.""" config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 - ent_reg = er.async_get(hass) - # create (deviceless) entity with V1 schema in registry # using the legacy style group id as unique id - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "light", hue.DOMAIN, "3", @@ -176,22 +182,24 @@ async def test_group_entity_migration_with_v1_id( await hue.migration.handle_v2_migration(hass, config_entry) # the entity should have the new identifier (guid) - migrated_entity = ent_reg.async_get("light.hue_migrated_grouped_light") + migrated_entity = entity_registry.async_get("light.hue_migrated_grouped_light") assert migrated_entity is not None assert migrated_entity.unique_id == "e937f8db-2f0e-49a0-936e-027e60e15b34" async def test_group_entity_migration_with_v2_group_id( - hass: HomeAssistant, mock_bridge_v2, mock_config_entry_v2, v2_resources_test_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_bridge_v2, + mock_config_entry_v2, + v2_resources_test_data, ) -> None: """Test if entity schema for grouped_lights migrates from v1 to v2.""" config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 - ent_reg = er.async_get(hass) - # create (deviceless) entity with V1 schema in registry # using the V2 group id as unique id - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "light", hue.DOMAIN, "6ddc9066-7e7d-4a03-a773-c73937968296", @@ -209,6 +217,6 @@ async def test_group_entity_migration_with_v2_group_id( await hue.migration.handle_v2_migration(hass, config_entry) # the entity should have the new identifier (guid) - migrated_entity = ent_reg.async_get("light.hue_migrated_grouped_light") + migrated_entity = entity_registry.async_get("light.hue_migrated_grouped_light") assert migrated_entity is not None assert migrated_entity.unique_id == "e937f8db-2f0e-49a0-936e-027e60e15b34" diff --git a/tests/components/hue/test_scene.py b/tests/components/hue/test_scene.py index 5fa35cec5b4..ad2d11ff6b6 100644 --- a/tests/components/hue/test_scene.py +++ b/tests/components/hue/test_scene.py @@ -8,7 +8,10 @@ from .const import FAKE_SCENE async def test_scene( - hass: HomeAssistant, mock_bridge_v2, v2_resources_test_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_bridge_v2, + v2_resources_test_data, ) -> None: """Test if (config) scenes get created.""" await mock_bridge_v2.api.load_test_data(v2_resources_test_data) @@ -57,13 +60,12 @@ async def test_scene( assert test_entity.attributes["is_active"] is True # scene entities should have be assigned to the room/zone device/service - ent_reg = er.async_get(hass) for entity_id in ( "scene.test_zone_dynamic_test_scene", "scene.test_room_regular_test_scene", "scene.test_room_smart_test_scene", ): - entity_entry = ent_reg.async_get(entity_id) + entity_entry = entity_registry.async_get(entity_id) assert entity_entry assert entity_entry.device_id is not None diff --git a/tests/components/hue/test_sensor_v2.py b/tests/components/hue/test_sensor_v2.py index 45e39e94119..b8793c99d6c 100644 --- a/tests/components/hue/test_sensor_v2.py +++ b/tests/components/hue/test_sensor_v2.py @@ -9,7 +9,10 @@ from .const import FAKE_DEVICE, FAKE_SENSOR, FAKE_ZIGBEE_CONNECTIVITY async def test_sensors( - hass: HomeAssistant, mock_bridge_v2, v2_resources_test_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_bridge_v2, + v2_resources_test_data, ) -> None: """Test if all v2 sensors get created with correct features.""" await mock_bridge_v2.api.load_test_data(v2_resources_test_data) @@ -51,8 +54,7 @@ async def test_sensors( # test disabled zigbee_connectivity sensor entity_id = "sensor.wall_switch_with_2_controls_zigbee_connectivity" - ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(entity_id) + entity_entry = entity_registry.async_get(entity_id) assert entity_entry assert entity_entry.disabled @@ -60,7 +62,11 @@ async def test_sensors( async def test_enable_sensor( - hass: HomeAssistant, mock_bridge_v2, v2_resources_test_data, mock_config_entry_v2 + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_bridge_v2, + v2_resources_test_data, + mock_config_entry_v2, ) -> None: """Test enabling of the by default disabled zigbee_connectivity sensor.""" await mock_bridge_v2.api.load_test_data(v2_resources_test_data) @@ -71,15 +77,14 @@ async def test_enable_sensor( await hass.config_entries.async_forward_entry_setup(mock_config_entry_v2, "sensor") entity_id = "sensor.wall_switch_with_2_controls_zigbee_connectivity" - ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(entity_id) + entity_entry = entity_registry.async_get(entity_id) assert entity_entry assert entity_entry.disabled assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # enable the entity - updated_entry = ent_reg.async_update_entity( + updated_entry = entity_registry.async_update_entity( entity_entry.entity_id, **{"disabled_by": None} ) assert updated_entry != entity_entry diff --git a/tests/components/hydrawise/conftest.py b/tests/components/hydrawise/conftest.py index 4a6c8372e57..1f892785812 100644 --- a/tests/components/hydrawise/conftest.py +++ b/tests/components/hydrawise/conftest.py @@ -1,14 +1,23 @@ """Common fixtures for the Hydrawise tests.""" -from collections.abc import Generator -from typing import Any -from unittest.mock import AsyncMock, Mock, patch +from collections.abc import Awaitable, Callable, Generator +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, patch +from pydrawise.schema import ( + Controller, + ControllerHardware, + ScheduledZoneRun, + ScheduledZoneRuns, + User, + Zone, +) import pytest from homeassistant.components.hydrawise.const import DOMAIN from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry @@ -24,59 +33,71 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture def mock_pydrawise( - mock_controller: dict[str, Any], - mock_zones: list[dict[str, Any]], -) -> Generator[Mock, None, None]: - """Mock LegacyHydrawise.""" - with patch("pydrawise.legacy.LegacyHydrawise", autospec=True) as mock_pydrawise: - mock_pydrawise.return_value.controller_info = {"controllers": [mock_controller]} - mock_pydrawise.return_value.current_controller = mock_controller - mock_pydrawise.return_value.controller_status = {"relays": mock_zones} - mock_pydrawise.return_value.relays = mock_zones - mock_pydrawise.return_value.relays_by_zone_number = { - r["relay"]: r for r in mock_zones - } + user: User, + controller: Controller, + zones: list[Zone], +) -> Generator[AsyncMock, None, None]: + """Mock LegacyHydrawiseAsync.""" + with patch( + "pydrawise.legacy.LegacyHydrawiseAsync", autospec=True + ) as mock_pydrawise: + user.controllers = [controller] + controller.zones = zones + mock_pydrawise.return_value.get_user.return_value = user yield mock_pydrawise.return_value @pytest.fixture -def mock_controller() -> dict[str, Any]: - """Mock Hydrawise controller.""" - return { - "name": "Home Controller", - "last_contact": 1693292420, - "serial_number": "0310b36090", - "controller_id": 52496, - "status": "Unknown", - } +def user() -> User: + """Hydrawise User fixture.""" + return User(customer_id=12345) @pytest.fixture -def mock_zones() -> list[dict[str, Any]]: - """Mock Hydrawise zones.""" +def controller() -> Controller: + """Hydrawise Controller fixture.""" + return Controller( + id=52496, + name="Home Controller", + hardware=ControllerHardware( + serial_number="0310b36090", + ), + last_contact_time=datetime.fromtimestamp(1693292420), + online=True, + ) + + +@pytest.fixture +def zones() -> list[Zone]: + """Hydrawise zone fixtures.""" return [ - { - "name": "Zone One", - "period": 259200, - "relay": 1, - "relay_id": 5965394, - "run": 1800, - "stop": 1, - "time": 330597, - "timestr": "Sat", - "type": 1, - }, - { - "name": "Zone Two", - "period": 259200, - "relay": 2, - "relay_id": 5965395, - "run": 1788, - "stop": 1, - "time": 1, - "timestr": "Now", - "type": 106, - }, + Zone( + name="Zone One", + number=1, + id=5965394, + scheduled_runs=ScheduledZoneRuns( + summary="", + current_run=None, + next_run=ScheduledZoneRun( + start_time=dt_util.now() + timedelta(seconds=330597), + end_time=dt_util.now() + + timedelta(seconds=330597) + + timedelta(seconds=1800), + normal_duration=timedelta(seconds=1800), + duration=timedelta(seconds=1800), + ), + ), + ), + Zone( + name="Zone Two", + number=2, + id=5965395, + scheduled_runs=ScheduledZoneRuns( + current_run=ScheduledZoneRun( + remaining_time=timedelta(seconds=1788), + ), + ), + ), ] @@ -95,13 +116,25 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture async def mock_added_config_entry( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_pydrawise: Mock, + mock_add_config_entry: Callable[[], Awaitable[MockConfigEntry]] ) -> MockConfigEntry: """Mock ConfigEntry that's been added to HA.""" - 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 DOMAIN in hass.config_entries.async_domains() - return mock_config_entry + return await mock_add_config_entry() + + +@pytest.fixture +async def mock_add_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pydrawise: AsyncMock, +) -> Callable[[], Awaitable[MockConfigEntry]]: + """Callable that creates a mock ConfigEntry that's been added to HA.""" + + async def callback() -> MockConfigEntry: + 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 DOMAIN in hass.config_entries.async_domains() + return mock_config_entry + + return callback diff --git a/tests/components/hydrawise/test_binary_sensor.py b/tests/components/hydrawise/test_binary_sensor.py index c60f4392f1e..f4702758136 100644 --- a/tests/components/hydrawise/test_binary_sensor.py +++ b/tests/components/hydrawise/test_binary_sensor.py @@ -1,8 +1,9 @@ """Test Hydrawise binary_sensor.""" from datetime import timedelta -from unittest.mock import Mock +from unittest.mock import AsyncMock +from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory from homeassistant.components.hydrawise.const import SCAN_INTERVAL @@ -33,12 +34,13 @@ async def test_states( async def test_update_data_fails( hass: HomeAssistant, mock_added_config_entry: MockConfigEntry, - mock_pydrawise: Mock, + mock_pydrawise: AsyncMock, freezer: FrozenDateTimeFactory, ) -> None: """Test that no data from the API sets the correct connectivity.""" # Make the coordinator refresh data. - mock_pydrawise.update_controller_info.return_value = None + mock_pydrawise.get_user.reset_mock(return_value=True) + mock_pydrawise.get_user.side_effect = ClientError freezer.tick(SCAN_INTERVAL + timedelta(seconds=30)) async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/hydrawise/test_config_flow.py b/tests/components/hydrawise/test_config_flow.py index c9efbea507e..17c3eda1699 100644 --- a/tests/components/hydrawise/test_config_flow.py +++ b/tests/components/hydrawise/test_config_flow.py @@ -1,9 +1,10 @@ """Test the Hydrawise config flow.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock +from aiohttp import ClientError +from pydrawise.schema import User import pytest -from requests.exceptions import ConnectTimeout, HTTPError from homeassistant import config_entries from homeassistant.components.hydrawise.const import DOMAIN @@ -17,9 +18,11 @@ from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") -@patch("pydrawise.legacy.LegacyHydrawise") async def test_form( - mock_api: MagicMock, hass: HomeAssistant, mock_setup_entry: AsyncMock + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_pydrawise: AsyncMock, + user: User, ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -32,19 +35,22 @@ async def test_form( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"api_key": "abc123"} ) - mock_api.return_value.customer_id = 12345 + mock_pydrawise.get_user.return_value = user await hass.async_block_till_done() assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["title"] == "Hydrawise" assert result2["data"] == {"api_key": "abc123"} assert len(mock_setup_entry.mock_calls) == 1 + mock_pydrawise.get_user.assert_called_once_with(fetch_zones=False) -@patch("pydrawise.legacy.LegacyHydrawise") -async def test_form_api_error(mock_api: MagicMock, hass: HomeAssistant) -> None: +async def test_form_api_error( + hass: HomeAssistant, mock_pydrawise: AsyncMock, user: User +) -> None: """Test we handle API errors.""" - mock_api.side_effect = HTTPError + mock_pydrawise.get_user.side_effect = ClientError("XXX") + init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -55,15 +61,17 @@ async def test_form_api_error(mock_api: MagicMock, hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} - mock_api.side_effect = None + mock_pydrawise.get_user.reset_mock(side_effect=True) + mock_pydrawise.get_user.return_value = user result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) assert result2["type"] == FlowResultType.CREATE_ENTRY -@patch("pydrawise.legacy.LegacyHydrawise") -async def test_form_connect_timeout(mock_api: MagicMock, hass: HomeAssistant) -> None: +async def test_form_connect_timeout( + hass: HomeAssistant, mock_pydrawise: AsyncMock, user: User +) -> None: """Test we handle API errors.""" - mock_api.side_effect = ConnectTimeout + mock_pydrawise.get_user.side_effect = TimeoutError init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -75,15 +83,17 @@ async def test_form_connect_timeout(mock_api: MagicMock, hass: HomeAssistant) -> assert result["type"] == FlowResultType.FORM assert result["errors"] == {"base": "timeout_connect"} - mock_api.side_effect = None + mock_pydrawise.get_user.reset_mock(side_effect=True) + mock_pydrawise.get_user.return_value = user result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) assert result2["type"] == FlowResultType.CREATE_ENTRY -@patch("pydrawise.legacy.LegacyHydrawise") -async def test_flow_import_success(mock_api: MagicMock, hass: HomeAssistant) -> None: +async def test_flow_import_success( + hass: HomeAssistant, mock_pydrawise: AsyncMock, user: User +) -> None: """Test that we can import a YAML config.""" - mock_api.return_value.status = "All good!" + mock_pydrawise.get_user.return_value = User result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, @@ -107,9 +117,11 @@ async def test_flow_import_success(mock_api: MagicMock, hass: HomeAssistant) -> assert issue.translation_key == "deprecated_yaml" -@patch("pydrawise.legacy.LegacyHydrawise", side_effect=HTTPError) -async def test_flow_import_api_error(mock_api: MagicMock, hass: HomeAssistant) -> None: +async def test_flow_import_api_error( + hass: HomeAssistant, mock_pydrawise: AsyncMock +) -> None: """Test that we handle API errors on YAML import.""" + mock_pydrawise.get_user.side_effect = ClientError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, @@ -129,11 +141,11 @@ async def test_flow_import_api_error(mock_api: MagicMock, hass: HomeAssistant) - assert issue.translation_key == "deprecated_yaml_import_issue" -@patch("pydrawise.legacy.LegacyHydrawise", side_effect=ConnectTimeout) async def test_flow_import_connect_timeout( - mock_api: MagicMock, hass: HomeAssistant + hass: HomeAssistant, mock_pydrawise: AsyncMock ) -> None: """Test that we handle connection timeouts on YAML import.""" + mock_pydrawise.get_user.side_effect = TimeoutError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, @@ -153,32 +165,8 @@ async def test_flow_import_connect_timeout( assert issue.translation_key == "deprecated_yaml_import_issue" -@patch("pydrawise.legacy.LegacyHydrawise") -async def test_flow_import_no_status(mock_api: MagicMock, hass: HomeAssistant) -> None: - """Test we handle a lack of API status on YAML import.""" - mock_api.return_value.status = None - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_API_KEY: "__api_key__", - CONF_SCAN_INTERVAL: 120, - }, - ) - await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "unknown" - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue( - DOMAIN, "deprecated_yaml_import_issue_unknown" - ) - assert issue.translation_key == "deprecated_yaml_import_issue" - - -@patch("pydrawise.legacy.LegacyHydrawise") async def test_flow_import_already_imported( - mock_api: MagicMock, hass: HomeAssistant + hass: HomeAssistant, mock_pydrawise: AsyncMock, user: User ) -> None: """Test that we can handle a YAML config already imported.""" mock_config_entry = MockConfigEntry( @@ -187,12 +175,12 @@ async def test_flow_import_already_imported( data={ CONF_API_KEY: "__api_key__", }, - unique_id="hydrawise-CUSTOMER_ID", + unique_id="hydrawise-12345", ) mock_config_entry.add_to_hass(hass) - mock_api.return_value.customer_id = "CUSTOMER_ID" - mock_api.return_value.status = "All good!" + mock_pydrawise.get_user.return_value = user + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, diff --git a/tests/components/hydrawise/test_device.py b/tests/components/hydrawise/test_device.py index 05c402faca7..9d98f2a7b44 100644 --- a/tests/components/hydrawise/test_device.py +++ b/tests/components/hydrawise/test_device.py @@ -9,10 +9,12 @@ from homeassistant.helpers import device_registry as dr def test_zones_in_device_registry( - hass: HomeAssistant, mock_added_config_entry: ConfigEntry, mock_pydrawise: Mock + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_added_config_entry: ConfigEntry, + mock_pydrawise: Mock, ) -> None: """Test that devices are added to the device registry.""" - device_registry = dr.async_get(hass) device1 = device_registry.async_get_device(identifiers={(DOMAIN, "5965394")}) assert device1 is not None @@ -26,10 +28,12 @@ def test_zones_in_device_registry( def test_controller_in_device_registry( - hass: HomeAssistant, mock_added_config_entry: ConfigEntry, mock_pydrawise: Mock + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_added_config_entry: ConfigEntry, + mock_pydrawise: Mock, ) -> None: """Test that devices are added to the device registry.""" - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, "52496")}) assert device is not None assert device.name == "Home Controller" diff --git a/tests/components/hydrawise/test_init.py b/tests/components/hydrawise/test_init.py index 79cea94d479..6b41867b044 100644 --- a/tests/components/hydrawise/test_init.py +++ b/tests/components/hydrawise/test_init.py @@ -1,8 +1,8 @@ """Tests for the Hydrawise integration.""" -from unittest.mock import Mock +from unittest.mock import AsyncMock -from requests.exceptions import HTTPError +from aiohttp import ClientError from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN @@ -13,11 +13,10 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -async def test_setup_import_success(hass: HomeAssistant, mock_pydrawise: Mock) -> None: +async def test_setup_import_success( + hass: HomeAssistant, mock_pydrawise: AsyncMock +) -> None: """Test that setup with a YAML config triggers an import and warning.""" - mock_pydrawise.update_controller_info.return_value = True - mock_pydrawise.customer_id = 12345 - mock_pydrawise.status = "unknown" config = {"hydrawise": {CONF_ACCESS_TOKEN: "_access-token_"}} assert await async_setup_component(hass, "hydrawise", config) await hass.async_block_till_done() @@ -30,21 +29,10 @@ async def test_setup_import_success(hass: HomeAssistant, mock_pydrawise: Mock) - async def test_connect_retry( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pydrawise: Mock + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pydrawise: AsyncMock ) -> None: """Test that a connection error triggers a retry.""" - mock_pydrawise.update_controller_info.side_effect = HTTPError - 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_no_data( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pydrawise: Mock -) -> None: - """Test that no data from the API triggers a retry.""" - mock_pydrawise.update_controller_info.return_value = False + mock_pydrawise.get_user.side_effect = ClientError 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/hydrawise/test_sensor.py b/tests/components/hydrawise/test_sensor.py index c6d3fecab65..f0edb79b349 100644 --- a/tests/components/hydrawise/test_sensor.py +++ b/tests/components/hydrawise/test_sensor.py @@ -1,6 +1,9 @@ """Test Hydrawise sensor.""" +from collections.abc import Awaitable, Callable + from freezegun.api import FrozenDateTimeFactory +from pydrawise.schema import Zone import pytest from homeassistant.core import HomeAssistant @@ -26,3 +29,18 @@ async def test_states( next_cycle = hass.states.get("sensor.zone_one_next_cycle") assert next_cycle is not None assert next_cycle.state == "2023-10-04T19:49:57+00:00" + + +@pytest.mark.freeze_time("2023-10-01 00:00:00+00:00") +async def test_suspended_state( + hass: HomeAssistant, + zones: list[Zone], + mock_add_config_entry: Callable[[], Awaitable[MockConfigEntry]], +) -> None: + """Test sensor states.""" + zones[0].scheduled_runs.next_run = None + await mock_add_config_entry() + + next_cycle = hass.states.get("sensor.zone_one_next_cycle") + assert next_cycle is not None + assert next_cycle.state == "9999-12-31T23:59:59+00:00" diff --git a/tests/components/hydrawise/test_switch.py b/tests/components/hydrawise/test_switch.py index 39d789f4cf9..30a58735122 100644 --- a/tests/components/hydrawise/test_switch.py +++ b/tests/components/hydrawise/test_switch.py @@ -1,12 +1,16 @@ """Test Hydrawise switch.""" -from unittest.mock import Mock +from datetime import timedelta +from unittest.mock import AsyncMock -from freezegun.api import FrozenDateTimeFactory +from pydrawise.schema import Zone +import pytest +from homeassistant.components.hydrawise.const import DEFAULT_WATERING_TIME from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry @@ -14,7 +18,6 @@ from tests.common import MockConfigEntry async def test_states( hass: HomeAssistant, mock_added_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, ) -> None: """Test switch states.""" watering1 = hass.states.get("switch.zone_one_manual_watering") @@ -31,11 +34,14 @@ async def test_states( auto_watering2 = hass.states.get("switch.zone_two_automatic_watering") assert auto_watering2 is not None - assert auto_watering2.state == "off" + assert auto_watering2.state == "on" async def test_manual_watering_services( - hass: HomeAssistant, mock_added_config_entry: MockConfigEntry, mock_pydrawise: Mock + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_pydrawise: AsyncMock, + zones: list[Zone], ) -> None: """Test Manual Watering services.""" await hass.services.async_call( @@ -44,7 +50,12 @@ async def test_manual_watering_services( service_data={ATTR_ENTITY_ID: "switch.zone_one_manual_watering"}, blocking=True, ) - mock_pydrawise.run_zone.assert_called_once_with(15, 1) + mock_pydrawise.start_zone.assert_called_once_with( + zones[0], custom_run_duration=DEFAULT_WATERING_TIME + ) + state = hass.states.get("switch.zone_one_manual_watering") + assert state is not None + assert state.state == "on" mock_pydrawise.reset_mock() await hass.services.async_call( @@ -53,11 +64,18 @@ async def test_manual_watering_services( service_data={ATTR_ENTITY_ID: "switch.zone_one_manual_watering"}, blocking=True, ) - mock_pydrawise.run_zone.assert_called_once_with(0, 1) + mock_pydrawise.stop_zone.assert_called_once_with(zones[0]) + state = hass.states.get("switch.zone_one_manual_watering") + assert state is not None + assert state.state == "off" +@pytest.mark.freeze_time("2023-10-01 00:00:00+00:00") async def test_auto_watering_services( - hass: HomeAssistant, mock_added_config_entry: MockConfigEntry, mock_pydrawise: Mock + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_pydrawise: AsyncMock, + zones: list[Zone], ) -> None: """Test Automatic Watering services.""" await hass.services.async_call( @@ -66,7 +84,12 @@ async def test_auto_watering_services( service_data={ATTR_ENTITY_ID: "switch.zone_one_automatic_watering"}, blocking=True, ) - mock_pydrawise.suspend_zone.assert_called_once_with(365, 1) + mock_pydrawise.suspend_zone.assert_called_once_with( + zones[0], dt_util.now() + timedelta(days=365) + ) + state = hass.states.get("switch.zone_one_automatic_watering") + assert state is not None + assert state.state == "off" mock_pydrawise.reset_mock() await hass.services.async_call( @@ -75,4 +98,7 @@ async def test_auto_watering_services( service_data={ATTR_ENTITY_ID: "switch.zone_one_automatic_watering"}, blocking=True, ) - mock_pydrawise.suspend_zone.assert_called_once_with(0, 1) + mock_pydrawise.resume_zone.assert_called_once_with(zones[0]) + state = hass.states.get("switch.zone_one_automatic_watering") + assert state is not None + assert state.state == "on" diff --git a/tests/components/hyperion/test_camera.py b/tests/components/hyperion/test_camera.py index a6234f34593..e087b0fc1a5 100644 --- a/tests/components/hyperion/test_camera.py +++ b/tests/components/hyperion/test_camera.py @@ -177,7 +177,11 @@ async def test_camera_stream_failed_start_stream_call(hass: HomeAssistant) -> No assert not client.async_send_image_stream_stop.called -async def test_device_info(hass: HomeAssistant) -> None: +async def test_device_info( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Verify device information includes expected details.""" client = create_mock_client() @@ -190,7 +194,6 @@ async def test_device_info(hass: HomeAssistant) -> None: await setup_test_config_entry(hass, hyperion_client=client) device_id = get_hyperion_device_id(TEST_SYSINFO_ID, TEST_INSTANCE) - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device @@ -200,7 +203,6 @@ async def test_device_info(hass: HomeAssistant) -> None: assert device.model == HYPERION_MODEL_NAME assert device.name == TEST_INSTANCE_1["friendly_name"] - entity_registry = er.async_get(hass) entities_from_device = [ entry.entity_id for entry in er.async_entries_for_device(entity_registry, device.id) diff --git a/tests/components/hyperion/test_light.py b/tests/components/hyperion/test_light.py index 6c4cc4e512e..01cc1c7d9d2 100644 --- a/tests/components/hyperion/test_light.py +++ b/tests/components/hyperion/test_light.py @@ -114,10 +114,11 @@ async def test_setup_config_entry_not_ready_load_state_fail( assert hass.states.get(TEST_ENTITY_ID_1) is None -async def test_setup_config_entry_dynamic_instances(hass: HomeAssistant) -> None: +async def test_setup_config_entry_dynamic_instances( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Test dynamic changes in the instance configuration.""" - registry = er.async_get(hass) - config_entry = add_test_config_entry(hass) master_client = create_mock_client() @@ -164,7 +165,7 @@ async def test_setup_config_entry_dynamic_instances(hass: HomeAssistant) -> None assert hass.states.get(TEST_ENTITY_ID_3) is not None # Instance 1 is stopped, it should still be registered. - assert registry.async_is_registered(TEST_ENTITY_ID_1) + assert entity_registry.async_is_registered(TEST_ENTITY_ID_1) # == Inject a new instances update (remove instance 1) assert master_client.set_callbacks.called @@ -188,7 +189,7 @@ async def test_setup_config_entry_dynamic_instances(hass: HomeAssistant) -> None assert hass.states.get(TEST_ENTITY_ID_3) is not None # Instance 1 is removed, it should not still be registered. - assert not registry.async_is_registered(TEST_ENTITY_ID_1) + assert not entity_registry.async_is_registered(TEST_ENTITY_ID_1) # == Inject a new instances update (re-add instance 1, but not running) with patch( @@ -766,14 +767,17 @@ async def test_light_option_effect_hide_list(hass: HomeAssistant) -> None: ] -async def test_device_info(hass: HomeAssistant) -> None: +async def test_device_info( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Verify device information includes expected details.""" client = create_mock_client() await setup_test_config_entry(hass, hyperion_client=client) device_id = get_hyperion_device_id(TEST_SYSINFO_ID, TEST_INSTANCE) - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device @@ -783,7 +787,6 @@ async def test_device_info(hass: HomeAssistant) -> None: assert device.model == HYPERION_MODEL_NAME assert device.name == TEST_INSTANCE_1["friendly_name"] - entity_registry = er.async_get(hass) entities_from_device = [ entry.entity_id for entry in er.async_entries_for_device(entity_registry, device.id) diff --git a/tests/components/hyperion/test_switch.py b/tests/components/hyperion/test_switch.py index dcdd86f0902..79b9454e29f 100644 --- a/tests/components/hyperion/test_switch.py +++ b/tests/components/hyperion/test_switch.py @@ -144,7 +144,11 @@ async def test_switch_has_correct_entities(hass: HomeAssistant) -> None: assert entity_state, f"Couldn't find entity: {entity_id}" -async def test_device_info(hass: HomeAssistant) -> None: +async def test_device_info( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Verify device information includes expected details.""" client = create_mock_client() client.components = TEST_COMPONENTS @@ -162,7 +166,6 @@ async def test_device_info(hass: HomeAssistant) -> None: assert hass.states.get(TEST_SWITCH_COMPONENT_ALL_ENTITY_ID) is not None device_identifer = get_hyperion_device_id(TEST_SYSINFO_ID, TEST_INSTANCE) - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, device_identifer)}) assert device @@ -172,7 +175,6 @@ async def test_device_info(hass: HomeAssistant) -> None: assert device.model == HYPERION_MODEL_NAME assert device.name == TEST_INSTANCE_1["friendly_name"] - entity_registry = er.async_get(hass) entities_from_device = [ entry.entity_id for entry in er.async_entries_for_device(entity_registry, device.id) @@ -184,14 +186,14 @@ async def test_device_info(hass: HomeAssistant) -> None: assert entity_id in entities_from_device -async def test_switches_can_be_enabled(hass: HomeAssistant) -> None: +async def test_switches_can_be_enabled( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Verify switches can be enabled.""" client = create_mock_client() client.components = TEST_COMPONENTS await setup_test_config_entry(hass, hyperion_client=client) - entity_registry = er.async_get(hass) - for component in TEST_COMPONENTS: name = slugify(KEY_COMPONENTID_TO_NAME[str(component["name"])]) entity_id = TEST_SWITCH_COMPONENT_BASE_ENTITY_ID + "_" + name diff --git a/tests/components/iaqualink/test_init.py b/tests/components/iaqualink/test_init.py index 7b61b42c9d2..646e9e4da86 100644 --- a/tests/components/iaqualink/test_init.py +++ b/tests/components/iaqualink/test_init.py @@ -114,7 +114,8 @@ async def test_setup_devices_exception( "homeassistant.components.iaqualink.AqualinkClient.get_systems", return_value=systems, ), patch.object( - system, "get_devices" + system, + "get_devices", ) as mock_get_devices: mock_get_devices.side_effect = AqualinkServiceException await hass.config_entries.async_setup(config_entry.entry_id) @@ -142,7 +143,8 @@ async def test_setup_all_good_no_recognized_devices( "homeassistant.components.iaqualink.AqualinkClient.get_systems", return_value=systems, ), patch.object( - system, "get_devices" + system, + "get_devices", ) as mock_get_devices: mock_get_devices.return_value = devices await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/ibeacon/test_init.py b/tests/components/ibeacon/test_init.py index 2e3aafb4984..b29cc3a4b2e 100644 --- a/tests/components/ibeacon/test_init.py +++ b/tests/components/ibeacon/test_init.py @@ -33,7 +33,9 @@ async def remove_device(ws_client, device_id, config_entry_id): async def test_device_remove_devices( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + hass_ws_client: WebSocketGenerator, ) -> None: """Test we can only remove a device that no longer exists.""" entry = MockConfigEntry( @@ -46,7 +48,6 @@ async def test_device_remove_devices( await hass.async_block_till_done() inject_bluetooth_service_info(hass, BLUECHARM_BEACON_SERVICE_INFO) await hass.async_block_till_done() - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device( identifiers={ diff --git a/tests/components/idasen_desk/conftest.py b/tests/components/idasen_desk/conftest.py index d6c2ba5ad6b..8159039aff4 100644 --- a/tests/components/idasen_desk/conftest.py +++ b/tests/components/idasen_desk/conftest.py @@ -55,6 +55,7 @@ def mock_desk_api(): mock_desk.move_up = AsyncMock(side_effect=mock_move_up) mock_desk.move_down = AsyncMock(side_effect=mock_move_down) mock_desk.stop = AsyncMock() + mock_desk.height = 1 mock_desk.height_percent = 60 mock_desk.is_moving = False mock_desk.address = "AA:BB:CC:DD:EE:FF" diff --git a/tests/components/idasen_desk/test_buttons.py b/tests/components/idasen_desk/test_buttons.py new file mode 100644 index 00000000000..d576b2fe580 --- /dev/null +++ b/tests/components/idasen_desk/test_buttons.py @@ -0,0 +1,33 @@ +"""Test the IKEA Idasen Desk connection buttons.""" +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant + +from . import init_integration + + +async def test_connect_button( + hass: HomeAssistant, + mock_desk_api: MagicMock, +) -> None: + """Test pressing the connect button.""" + await init_integration(hass) + + await hass.services.async_call( + "button", "press", {"entity_id": "button.test_connect"}, blocking=True + ) + assert mock_desk_api.connect.call_count == 2 + + +async def test_disconnect_button( + hass: HomeAssistant, + mock_desk_api: MagicMock, +) -> None: + """Test pressing the disconnect button.""" + await init_integration(hass) + mock_desk_api.is_connected = True + + await hass.services.async_call( + "button", "press", {"entity_id": "button.test_disconnect"}, blocking=True + ) + mock_desk_api.disconnect.assert_called_once() diff --git a/tests/components/idasen_desk/test_cover.py b/tests/components/idasen_desk/test_cover.py index a9c74be7081..4c8bf7806e0 100644 --- a/tests/components/idasen_desk/test_cover.py +++ b/tests/components/idasen_desk/test_cover.py @@ -2,6 +2,7 @@ from typing import Any from unittest.mock import MagicMock +from bleak.exc import BleakError import pytest from homeassistant.components.cover import ( @@ -19,6 +20,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from . import init_integration @@ -80,3 +82,34 @@ async def test_cover_services( assert state assert state.state == expected_state assert state.attributes[ATTR_CURRENT_POSITION] == expected_position + + +@pytest.mark.parametrize( + ("service", "service_data", "mock_method_name"), + [ + (SERVICE_SET_COVER_POSITION, {ATTR_POSITION: 100}, "move_to"), + (SERVICE_OPEN_COVER, {}, "move_up"), + (SERVICE_CLOSE_COVER, {}, "move_down"), + (SERVICE_STOP_COVER, {}, "stop"), + ], +) +async def test_cover_services_exception( + hass: HomeAssistant, + mock_desk_api: MagicMock, + service: str, + service_data: dict[str, Any], + mock_method_name: str, +) -> None: + """Test cover services exception handling.""" + entity_id = "cover.test" + await init_integration(hass) + fail_call = getattr(mock_desk_api, mock_method_name) + fail_call.side_effect = BleakError() + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + COVER_DOMAIN, + service, + {"entity_id": entity_id, **service_data}, + blocking=True, + ) + await hass.async_block_till_done() diff --git a/tests/components/idasen_desk/test_sensors.py b/tests/components/idasen_desk/test_sensors.py new file mode 100644 index 00000000000..23d7ac2447b --- /dev/null +++ b/tests/components/idasen_desk/test_sensors.py @@ -0,0 +1,27 @@ +"""Test the IKEA Idasen Desk sensors.""" +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant + +from . import init_integration + + +async def test_height_sensor( + hass: HomeAssistant, + mock_desk_api: MagicMock, + entity_registry_enabled_by_default: None, +) -> None: + """Test height sensor.""" + await init_integration(hass) + + entity_id = "sensor.test_height" + state = hass.states.get(entity_id) + assert state + assert state.state == "1" + + mock_desk_api.height = 1.2 + mock_desk_api.trigger_update_callback(None) + + state = hass.states.get(entity_id) + assert state + assert state.state == "1.2" diff --git a/tests/components/input_boolean/test_init.py b/tests/components/input_boolean/test_init.py index 65451856002..4caf914ca19 100644 --- a/tests/components/input_boolean/test_init.py +++ b/tests/components/input_boolean/test_init.py @@ -195,10 +195,11 @@ async def test_input_boolean_context( assert state2.context.user_id == hass_admin_user.id -async def test_reload(hass: HomeAssistant, hass_admin_user: MockUser) -> None: +async def test_reload( + hass: HomeAssistant, entity_registry: er.EntityRegistry, hass_admin_user: MockUser +) -> None: """Test reload service.""" count_start = len(hass.states.async_entity_ids()) - ent_reg = er.async_get(hass) _LOGGER.debug("ENTITIES @ start: %s", hass.states.async_entity_ids()) @@ -226,9 +227,9 @@ async def test_reload(hass: HomeAssistant, hass_admin_user: MockUser) -> None: assert state_3 is None assert state_2.state == STATE_ON - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is None with patch( "homeassistant.config.load_yaml_config_file", @@ -261,9 +262,9 @@ async def test_reload(hass: HomeAssistant, hass_admin_user: MockUser) -> None: assert state_2 is not None assert state_3 is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is not None assert state_2.state == STATE_ON # reload is not supposed to change entity state assert state_2.attributes.get(ATTR_FRIENDLY_NAME) == "Hello World reloaded" @@ -316,18 +317,20 @@ async def test_ws_list( async def test_ws_delete( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test WS delete cleans up entity registry.""" assert await storage_setup() input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -339,11 +342,14 @@ async def test_ws_delete( state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None async def test_ws_update( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test update WS.""" @@ -355,12 +361,11 @@ async def test_ws_update( input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is not None assert state.state - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -400,18 +405,20 @@ async def test_ws_update( async def test_ws_create( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test create WS.""" assert await storage_setup(items=[]) input_id = "new_input" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None client = await hass_ws_client(hass) diff --git a/tests/components/input_button/test_init.py b/tests/components/input_button/test_init.py index f3b4eef36f5..9233668c113 100644 --- a/tests/components/input_button/test_init.py +++ b/tests/components/input_button/test_init.py @@ -133,10 +133,11 @@ async def test_input_button_context( assert state2.context.user_id == hass_admin_user.id -async def test_reload(hass: HomeAssistant, hass_admin_user: MockUser) -> None: +async def test_reload( + hass: HomeAssistant, entity_registry: er.EntityRegistry, hass_admin_user: MockUser +) -> None: """Test reload service.""" count_start = len(hass.states.async_entity_ids()) - ent_reg = er.async_get(hass) _LOGGER.debug("ENTITIES @ start: %s", hass.states.async_entity_ids()) @@ -163,9 +164,9 @@ async def test_reload(hass: HomeAssistant, hass_admin_user: MockUser) -> None: assert state_2 is not None assert state_3 is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is None with patch( "homeassistant.config.load_yaml_config_file", @@ -197,9 +198,9 @@ async def test_reload(hass: HomeAssistant, hass_admin_user: MockUser) -> None: assert state_2 is not None assert state_3 is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is not None async def test_reload_not_changing_state(hass: HomeAssistant, storage_setup) -> None: @@ -288,7 +289,10 @@ async def test_ws_list( async def test_ws_create_update( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test creating and updating via WS.""" assert await storage_setup(config={DOMAIN: {}}) @@ -304,8 +308,7 @@ async def test_ws_create_update( assert state.state == STATE_UNKNOWN assert state.attributes.get(ATTR_FRIENDLY_NAME) == "new" - ent_reg = er.async_get(hass) - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "new") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "new") is not None await client.send_json( {"id": 8, "type": f"{DOMAIN}/update", f"{DOMAIN}_id": "new", "name": "newer"} @@ -319,22 +322,24 @@ async def test_ws_create_update( assert state.state == STATE_UNKNOWN assert state.attributes.get(ATTR_FRIENDLY_NAME) == "newer" - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "new") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "new") is not None async def test_ws_delete( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test WS delete cleans up entity registry.""" assert await storage_setup() input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -346,7 +351,7 @@ async def test_ws_delete( state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None async def test_setup_no_config(hass: HomeAssistant, hass_admin_user: MockUser) -> None: diff --git a/tests/components/input_datetime/test_init.py b/tests/components/input_datetime/test_init.py index 940d0ff6c55..a0b80ac420c 100644 --- a/tests/components/input_datetime/test_init.py +++ b/tests/components/input_datetime/test_init.py @@ -423,11 +423,13 @@ async def test_input_datetime_context( async def test_reload( - hass: HomeAssistant, hass_admin_user: MockUser, hass_read_only_user: MockUser + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_admin_user: MockUser, + hass_read_only_user: MockUser, ) -> None: """Test reload service.""" count_start = len(hass.states.async_entity_ids()) - ent_reg = er.async_get(hass) assert await async_setup_component( hass, @@ -451,9 +453,9 @@ async def test_reload( assert state_2 is None assert state_3 is not None assert dt_obj.strftime(FORMAT_DATE) == state_1.state - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "dt1") == f"{DOMAIN}.dt1" - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "dt2") is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "dt3") == f"{DOMAIN}.dt3" + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "dt1") == f"{DOMAIN}.dt1" + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "dt2") is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "dt3") == f"{DOMAIN}.dt3" with patch( "homeassistant.config.load_yaml_config_file", @@ -493,9 +495,9 @@ async def test_reload( datetime.date.today(), DEFAULT_TIME ).strftime(FORMAT_DATETIME) - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "dt1") == f"{DOMAIN}.dt1" - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "dt2") == f"{DOMAIN}.dt2" - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "dt3") is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "dt1") == f"{DOMAIN}.dt1" + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "dt2") == f"{DOMAIN}.dt2" + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "dt3") is None async def test_load_from_storage(hass: HomeAssistant, storage_setup) -> None: @@ -553,18 +555,22 @@ async def test_ws_list( async def test_ws_delete( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test WS delete cleans up entity registry.""" assert await storage_setup() input_id = "from_storage" input_entity_id = f"{DOMAIN}.datetime_from_storage" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) == input_entity_id + assert ( + entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) == input_entity_id + ) client = await hass_ws_client(hass) @@ -576,11 +582,14 @@ async def test_ws_delete( state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None async def test_update( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test updating min/max updates the state.""" @@ -588,12 +597,13 @@ async def test_update( input_id = "from_storage" input_entity_id = f"{DOMAIN}.datetime_from_storage" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state.attributes[ATTR_FRIENDLY_NAME] == "datetime from storage" assert state.state == INITIAL_DATETIME - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) == input_entity_id + assert ( + entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) == input_entity_id + ) client = await hass_ws_client(hass) @@ -621,18 +631,20 @@ async def test_update( async def test_ws_create( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test create WS.""" assert await storage_setup(items=[]) input_id = "new_datetime" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None client = await hass_ws_client(hass) diff --git a/tests/components/input_number/test_init.py b/tests/components/input_number/test_init.py index 3703ca39cd5..1334ba4aebd 100644 --- a/tests/components/input_number/test_init.py +++ b/tests/components/input_number/test_init.py @@ -343,11 +343,13 @@ async def test_input_number_context( async def test_reload( - hass: HomeAssistant, hass_admin_user: MockUser, hass_read_only_user: MockUser + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_admin_user: MockUser, + hass_read_only_user: MockUser, ) -> None: """Test reload service.""" count_start = len(hass.states.async_entity_ids()) - ent_reg = er.async_get(hass) assert await async_setup_component( hass, @@ -371,9 +373,9 @@ async def test_reload( assert state_3 is not None assert float(state_1.state) == 50 assert float(state_3.state) == 10 - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is not None with patch( "homeassistant.config.load_yaml_config_file", @@ -411,9 +413,9 @@ async def test_reload( assert state_3 is None assert float(state_1.state) == 50 assert float(state_2.state) == 20 - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is None async def test_load_from_storage(hass: HomeAssistant, storage_setup) -> None: @@ -486,18 +488,20 @@ async def test_ws_list( async def test_ws_delete( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test WS delete cleans up entity registry.""" assert await storage_setup() input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -509,11 +513,14 @@ async def test_ws_delete( state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None async def test_update_min_max( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test updating min/max updates the state.""" @@ -529,12 +536,11 @@ async def test_update_min_max( input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is not None assert state.state - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -572,18 +578,20 @@ async def test_update_min_max( async def test_ws_create( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test create WS.""" assert await storage_setup(items=[]) input_id = "new_input" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None client = await hass_ws_client(hass) diff --git a/tests/components/input_select/test_init.py b/tests/components/input_select/test_init.py index 6908a1c532e..3978d0cf175 100644 --- a/tests/components/input_select/test_init.py +++ b/tests/components/input_select/test_init.py @@ -447,11 +447,13 @@ async def test_input_select_context( async def test_reload( - hass: HomeAssistant, hass_admin_user: MockUser, hass_read_only_user: MockUser + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_admin_user: MockUser, + hass_read_only_user: MockUser, ) -> None: """Test reload service.""" count_start = len(hass.states.async_entity_ids()) - ent_reg = er.async_get(hass) assert await async_setup_component( hass, @@ -481,9 +483,9 @@ async def test_reload( assert state_3 is None assert state_1.state == "middle option" assert state_2.state == "an option" - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is None with patch( "homeassistant.config.load_yaml_config_file", @@ -526,9 +528,9 @@ async def test_reload( assert state_3 is not None assert state_2.state == "an option" assert state_3.state == "newer option" - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is not None async def test_load_from_storage(hass: HomeAssistant, storage_setup) -> None: @@ -611,18 +613,20 @@ async def test_ws_list( async def test_ws_delete( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test WS delete cleans up entity registry.""" assert await storage_setup() input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -634,11 +638,14 @@ async def test_ws_delete( state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None async def test_update( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test updating options updates the state.""" @@ -651,11 +658,10 @@ async def test_update( input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state.attributes[ATTR_OPTIONS] == ["yaml update 1", "yaml update 2"] - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -697,6 +703,7 @@ async def test_update( async def test_update_duplicates( hass: HomeAssistant, + entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, storage_setup, caplog: pytest.LogCaptureFixture, @@ -712,11 +719,10 @@ async def test_update_duplicates( input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state.attributes[ATTR_OPTIONS] == ["yaml update 1", "yaml update 2"] - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -734,7 +740,7 @@ async def test_update_duplicates( ) resp = await client.receive_json() assert not resp["success"] - assert resp["error"]["code"] == "unknown_error" + assert resp["error"]["code"] == "home_assistant_error" assert resp["error"]["message"] == "Duplicate options are not allowed" state = hass.states.get(input_entity_id) @@ -742,18 +748,20 @@ async def test_update_duplicates( async def test_ws_create( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test create WS.""" assert await storage_setup(items=[]) input_id = "new_input" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None client = await hass_ws_client(hass) @@ -776,6 +784,7 @@ async def test_ws_create( async def test_ws_create_duplicates( hass: HomeAssistant, + entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, storage_setup, caplog: pytest.LogCaptureFixture, @@ -785,11 +794,10 @@ async def test_ws_create_duplicates( input_id = "new_input" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None client = await hass_ws_client(hass) @@ -804,7 +812,7 @@ async def test_ws_create_duplicates( ) resp = await client.receive_json() assert not resp["success"] - assert resp["error"]["code"] == "unknown_error" + assert resp["error"]["code"] == "home_assistant_error" assert resp["error"]["message"] == "Duplicate options are not allowed" assert not hass.states.get(input_entity_id) diff --git a/tests/components/input_text/test_init.py b/tests/components/input_text/test_init.py index ea12eabd04f..23d1c3307e5 100644 --- a/tests/components/input_text/test_init.py +++ b/tests/components/input_text/test_init.py @@ -397,18 +397,20 @@ async def test_ws_list( async def test_ws_delete( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test WS delete cleans up entity registry.""" assert await storage_setup() input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -420,11 +422,14 @@ async def test_ws_delete( state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None async def test_update( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test updating min/max updates the state.""" @@ -432,13 +437,12 @@ async def test_update( input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state.attributes[ATTR_FRIENDLY_NAME] == "from storage" assert state.attributes[ATTR_MODE] == MODE_TEXT assert state.state == "loaded from storage" - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -470,18 +474,20 @@ async def test_update( async def test_ws_create( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test create WS.""" assert await storage_setup(items=[]) input_id = "new_input" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None client = await hass_ws_client(hass) diff --git a/tests/components/insteon/const.py b/tests/components/insteon/const.py index e731c51d6c6..53db12acb04 100644 --- a/tests/components/insteon/const.py +++ b/tests/components/insteon/const.py @@ -38,6 +38,10 @@ MOCK_USER_INPUT_PLM = { CONF_DEVICE: MOCK_DEVICE, } +MOCK_USER_INPUT_PLM_MANUAL = { + CONF_DEVICE: "manual", +} + MOCK_USER_INPUT_HUB_V2 = { CONF_HOST: MOCK_HOSTNAME, CONF_USERNAME: MOCK_USERNAME, diff --git a/tests/components/insteon/test_api_device.py b/tests/components/insteon/test_api_device.py index ce061e47c3d..7485914026a 100644 --- a/tests/components/insteon/test_api_device.py +++ b/tests/components/insteon/test_api_device.py @@ -87,7 +87,9 @@ async def test_no_ha_device( async def test_no_insteon_device( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, ) -> None: """Test response when no Insteon device exists.""" config_entry = MockConfigEntry( @@ -103,15 +105,14 @@ async def test_no_insteon_device( devices = MockDevices() await devices.async_load() - dev_reg = dr.async_get(hass) # Create device registry entry for a Insteon device not in the Insteon devices list - ha_device_1 = dev_reg.async_get_or_create( + ha_device_1 = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "AA.BB.CC")}, name="HA Device Only", ) # Create device registry entry for a non-Insteon device - ha_device_2 = dev_reg.async_get_or_create( + ha_device_2 = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={("other_domain", "no address")}, name="HA Device Only", diff --git a/tests/components/insteon/test_config_flow.py b/tests/components/insteon/test_config_flow.py index e15b7b2a287..106c93071be 100644 --- a/tests/components/insteon/test_config_flow.py +++ b/tests/components/insteon/test_config_flow.py @@ -1,5 +1,4 @@ """Test the config flow for the Insteon integration.""" - from unittest.mock import patch import pytest @@ -15,6 +14,7 @@ from homeassistant.components.insteon.config_flow import ( STEP_HUB_V1, STEP_HUB_V2, STEP_PLM, + STEP_PLM_MANUALLY, STEP_REMOVE_OVERRIDE, STEP_REMOVE_X10, ) @@ -45,6 +45,7 @@ from .const import ( MOCK_USER_INPUT_HUB_V1, MOCK_USER_INPUT_HUB_V2, MOCK_USER_INPUT_PLM, + MOCK_USER_INPUT_PLM_MANUAL, PATCH_ASYNC_SETUP, PATCH_ASYNC_SETUP_ENTRY, PATCH_CONNECTION, @@ -155,6 +156,41 @@ async def test_form_select_plm(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_select_plm_no_usb(hass: HomeAssistant) -> None: + """Test we set up the PLM when no comm ports are found.""" + + temp_usb_list = dict(USB_PORTS) + USB_PORTS.clear() + result = await _init_form(hass, STEP_PLM) + + result2, _, _ = await _device_form( + hass, result["flow_id"], mock_successful_connection, None + ) + USB_PORTS.update(temp_usb_list) + assert result2["type"] == "form" + assert result2["step_id"] == STEP_PLM_MANUALLY + + +async def test_form_select_plm_manual(hass: HomeAssistant) -> None: + """Test we set up the PLM correctly.""" + + result = await _init_form(hass, STEP_PLM) + + result2, mock_setup, mock_setup_entry = await _device_form( + hass, result["flow_id"], mock_failed_connection, MOCK_USER_INPUT_PLM_MANUAL + ) + + result3, mock_setup, mock_setup_entry = await _device_form( + hass, result2["flow_id"], mock_successful_connection, MOCK_USER_INPUT_PLM + ) + assert result2["type"] == "form" + assert result3["type"] == "create_entry" + assert result3["data"] == MOCK_USER_INPUT_PLM + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_form_select_hub_v1(hass: HomeAssistant) -> None: """Test we set up the Hub v1 correctly.""" @@ -225,6 +261,21 @@ async def test_failed_connection_plm(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} +async def test_failed_connection_plm_manually(hass: HomeAssistant) -> None: + """Test a failed connection with the PLM.""" + + result = await _init_form(hass, STEP_PLM) + + result2, _, _ = await _device_form( + hass, result["flow_id"], mock_successful_connection, MOCK_USER_INPUT_PLM_MANUAL + ) + result3, _, _ = await _device_form( + hass, result["flow_id"], mock_failed_connection, MOCK_USER_INPUT_PLM + ) + assert result3["type"] == "form" + assert result3["errors"] == {"base": "cannot_connect"} + + async def test_failed_connection_hub(hass: HomeAssistant) -> None: """Test a failed connection with a Hub.""" diff --git a/tests/components/insteon/test_init.py b/tests/components/insteon/test_init.py index 15f529babd8..f772eed2d26 100644 --- a/tests/components/insteon/test_init.py +++ b/tests/components/insteon/test_init.py @@ -76,7 +76,8 @@ async def test_import_frontend_dev_url(hass: HomeAssistant) -> None: ), patch.object(insteon, "close_insteon_connection"), patch.object( insteon, "devices", new=MockDevices() ), patch( - PATCH_CONNECTION, new=mock_successful_connection + PATCH_CONNECTION, + new=mock_successful_connection, ): assert await async_setup_component( hass, diff --git a/tests/components/insteon/test_lock.py b/tests/components/insteon/test_lock.py index 42a6d511b7e..c100acae3ce 100644 --- a/tests/components/insteon/test_lock.py +++ b/tests/components/insteon/test_lock.py @@ -47,7 +47,9 @@ def patch_setup_and_devices(): ), patch.object(insteon, "devices", devices), patch.object( insteon_utils, "devices", devices ), patch.object( - insteon_entity, "devices", devices + insteon_entity, + "devices", + devices, ): yield @@ -57,18 +59,20 @@ async def mock_connection(*args, **kwargs): return True -async def test_lock_lock(hass: HomeAssistant) -> None: +async def test_lock_lock( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Test locking an Insteon lock device.""" config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT_PLM) config_entry.add_to_hass(hass) - registry_entity = er.async_get(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() try: - lock = registry_entity.async_get("lock.device_55_55_55_55_55_55") + lock = entity_registry.async_get("lock.device_55_55_55_55_55_55") state = hass.states.get(lock.entity_id) assert state.state is STATE_UNLOCKED @@ -82,19 +86,21 @@ async def test_lock_lock(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_lock_unlock(hass: HomeAssistant) -> None: +async def test_lock_unlock( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Test locking an Insteon lock device.""" config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT_PLM) config_entry.add_to_hass(hass) - registry_entity = er.async_get(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() devices["55.55.55"].groups[1].set_value(255) try: - lock = registry_entity.async_get("lock.device_55_55_55_55_55_55") + lock = entity_registry.async_get("lock.device_55_55_55_55_55_55") state = hass.states.get(lock.entity_id) assert state.state is STATE_LOCKED diff --git a/tests/components/integration/test_init.py b/tests/components/integration/test_init.py index b68e3cdb1eb..885c10277f8 100644 --- a/tests/components/integration/test_init.py +++ b/tests/components/integration/test_init.py @@ -11,11 +11,11 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize("platform", ("sensor",)) async def test_setup_and_remove_config_entry( hass: HomeAssistant, + entity_registry: er.EntityRegistry, platform: str, ) -> None: """Test setting up and removing a config entry.""" input_sensor_entity_id = "sensor.input" - registry = er.async_get(hass) integration_entity_id = f"{platform}.my_integration" # Setup the config entry @@ -37,7 +37,7 @@ async def test_setup_and_remove_config_entry( await hass.async_block_till_done() # Check the entity is registered in the entity registry - assert registry.async_get(integration_entity_id) is not None + assert entity_registry.async_get(integration_entity_id) is not None # Check the platform is setup correctly state = hass.states.get(integration_entity_id) @@ -58,4 +58,4 @@ async def test_setup_and_remove_config_entry( # Check the state and entity registry entry are removed assert hass.states.get(integration_entity_id) is None - assert registry.async_get(integration_entity_id) is None + assert entity_registry.async_get(integration_entity_id) is None diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 0c2744dd654..8ef9caf4928 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -679,11 +679,12 @@ async def test_calc_errors(hass: HomeAssistant, method) -> None: assert round(float(state.state)) == 0 if method != "right" else 1 -async def test_device_id(hass: HomeAssistant) -> None: +async def test_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for source entity device for Riemann sum integral.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - source_config_entry = MockConfigEntry() source_config_entry.add_to_hass(hass) source_device_entry = device_registry.async_get_or_create( diff --git a/tests/components/intent/test_init.py b/tests/components/intent/test_init.py index fa8eb9cad61..d80add2a441 100644 --- a/tests/components/intent/test_init.py +++ b/tests/components/intent/test_init.py @@ -2,6 +2,7 @@ import pytest from homeassistant.components.cover import SERVICE_OPEN_COVER +from homeassistant.components.lock import SERVICE_LOCK from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, @@ -118,6 +119,44 @@ async def test_turn_on_intent(hass: HomeAssistant) -> None: assert call.data == {"entity_id": ["light.test_light"]} +async def test_translated_turn_on_intent( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test HassTurnOn intent on domains which don't have the intent.""" + result = await async_setup_component(hass, "homeassistant", {}) + result = await async_setup_component(hass, "intent", {}) + await hass.async_block_till_done() + assert result + + cover = entity_registry.async_get_or_create("cover", "test", "cover_uid") + lock = entity_registry.async_get_or_create("lock", "test", "lock_uid") + + hass.states.async_set(cover.entity_id, "closed") + hass.states.async_set(lock.entity_id, "unlocked") + cover_service_calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER) + lock_service_calls = async_mock_service(hass, "lock", SERVICE_LOCK) + + await intent.async_handle( + hass, "test", "HassTurnOn", {"name": {"value": cover.entity_id}} + ) + await intent.async_handle( + hass, "test", "HassTurnOn", {"name": {"value": lock.entity_id}} + ) + await hass.async_block_till_done() + + assert len(cover_service_calls) == 1 + call = cover_service_calls[0] + assert call.domain == "cover" + assert call.service == "open_cover" + assert call.data == {"entity_id": cover.entity_id} + + assert len(lock_service_calls) == 1 + call = lock_service_calls[0] + assert call.domain == "lock" + assert call.service == "lock" + assert call.data == {"entity_id": lock.entity_id} + + async def test_turn_off_intent(hass: HomeAssistant) -> None: """Test HassTurnOff intent.""" result = await async_setup_component(hass, "homeassistant", {}) diff --git a/tests/components/ipma/snapshots/test_weather.ambr b/tests/components/ipma/snapshots/test_weather.ambr index 92e1d1a91b5..0a778776329 100644 --- a/tests/components/ipma/snapshots/test_weather.ambr +++ b/tests/components/ipma/snapshots/test_weather.ambr @@ -36,6 +36,125 @@ ]), }) # --- +# name: test_forecast_service[forecast] + dict({ + 'weather.hometown': dict({ + 'forecast': list([ + dict({ + 'condition': 'rainy', + 'datetime': datetime.datetime(2020, 1, 16, 0, 0), + 'precipitation_probability': '100.0', + 'temperature': 16.2, + 'templow': 10.6, + 'wind_bearing': 'S', + 'wind_speed': 10.0, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast].1 + dict({ + 'weather.hometown': dict({ + 'forecast': list([ + dict({ + 'condition': 'rainy', + 'datetime': datetime.datetime(2020, 1, 15, 1, 0, tzinfo=datetime.timezone.utc), + 'precipitation_probability': 80.0, + 'temperature': 12.0, + 'wind_bearing': 'S', + 'wind_speed': 32.7, + }), + dict({ + 'condition': 'clear-night', + 'datetime': datetime.datetime(2020, 1, 15, 2, 0, tzinfo=datetime.timezone.utc), + 'precipitation_probability': 80.0, + 'temperature': 12.0, + 'wind_bearing': 'S', + 'wind_speed': 32.7, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecast] + dict({ + 'forecast': list([ + dict({ + 'condition': 'rainy', + 'datetime': datetime.datetime(2020, 1, 16, 0, 0), + 'precipitation_probability': '100.0', + 'temperature': 16.2, + 'templow': 10.6, + 'wind_bearing': 'S', + 'wind_speed': 10.0, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecast].1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'rainy', + 'datetime': datetime.datetime(2020, 1, 15, 1, 0, tzinfo=datetime.timezone.utc), + 'precipitation_probability': 80.0, + 'temperature': 12.0, + 'wind_bearing': 'S', + 'wind_speed': 32.7, + }), + dict({ + 'condition': 'clear-night', + 'datetime': datetime.datetime(2020, 1, 15, 2, 0, tzinfo=datetime.timezone.utc), + 'precipitation_probability': 80.0, + 'temperature': 12.0, + 'wind_bearing': 'S', + 'wind_speed': 32.7, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecasts] + dict({ + 'weather.hometown': dict({ + 'forecast': list([ + dict({ + 'condition': 'rainy', + 'datetime': datetime.datetime(2020, 1, 16, 0, 0), + 'precipitation_probability': '100.0', + 'temperature': 16.2, + 'templow': 10.6, + 'wind_bearing': 'S', + 'wind_speed': 10.0, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecasts].1 + dict({ + 'weather.hometown': dict({ + 'forecast': list([ + dict({ + 'condition': 'rainy', + 'datetime': datetime.datetime(2020, 1, 15, 1, 0, tzinfo=datetime.timezone.utc), + 'precipitation_probability': 80.0, + 'temperature': 12.0, + 'wind_bearing': 'S', + 'wind_speed': 32.7, + }), + dict({ + 'condition': 'clear-night', + 'datetime': datetime.datetime(2020, 1, 15, 2, 0, tzinfo=datetime.timezone.utc), + 'precipitation_probability': 80.0, + 'temperature': 12.0, + 'wind_bearing': 'S', + 'wind_speed': 32.7, + }), + ]), + }), + }) +# --- # name: test_forecast_subscription[daily] list([ dict({ diff --git a/tests/components/ipma/test_weather.py b/tests/components/ipma/test_weather.py index 71884e0c82e..9e0262733a3 100644 --- a/tests/components/ipma/test_weather.py +++ b/tests/components/ipma/test_weather.py @@ -22,7 +22,8 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED, DOMAIN as WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + LEGACY_SERVICE_GET_FORECAST, + SERVICE_GET_FORECASTS, ) from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -152,9 +153,17 @@ async def test_failed_get_observation_forecast(hass: HomeAssistant) -> None: assert state.attributes.get("friendly_name") == "HomeTown" +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) async def test_forecast_service( hass: HomeAssistant, snapshot: SnapshotAssertion, + service: str, ) -> None: """Test multiple forecast.""" @@ -169,7 +178,7 @@ async def test_forecast_service( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.hometown", "type": "daily", @@ -181,7 +190,7 @@ async def test_forecast_service( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.hometown", "type": "hourly", diff --git a/tests/components/ipp/test_sensor.py b/tests/components/ipp/test_sensor.py index 5992b928f63..cbcad903898 100644 --- a/tests/components/ipp/test_sensor.py +++ b/tests/components/ipp/test_sensor.py @@ -79,15 +79,14 @@ async def test_sensors( async def test_disabled_by_default_sensors( hass: HomeAssistant, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, ) -> None: """Test the disabled by default IPP sensors.""" - registry = er.async_get(hass) - state = hass.states.get("sensor.test_ha_1000_series_uptime") assert state is None - entry = registry.async_get("sensor.test_ha_1000_series_uptime") + entry = entity_registry.async_get("sensor.test_ha_1000_series_uptime") assert entry assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION @@ -95,6 +94,7 @@ async def test_disabled_by_default_sensors( async def test_missing_entry_unique_id( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, mock_ipp: AsyncMock, ) -> None: @@ -105,8 +105,6 @@ async def test_missing_entry_unique_id( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - registry = er.async_get(hass) - - entity = registry.async_get("sensor.test_ha_1000_series") + entity = entity_registry.async_get("sensor.test_ha_1000_series") assert entity assert entity.unique_id == f"{mock_config_entry.entry_id}_printer" diff --git a/tests/components/iqvia/conftest.py b/tests/components/iqvia/conftest.py index 075d7249d36..b24d473c7df 100644 --- a/tests/components/iqvia/conftest.py +++ b/tests/components/iqvia/conftest.py @@ -94,13 +94,9 @@ async def setup_iqvia_fixture( "pyiqvia.allergens.Allergens.outlook", return_value=data_allergy_outlook ), patch( "pyiqvia.asthma.Asthma.extended", return_value=data_asthma_forecast - ), patch( - "pyiqvia.asthma.Asthma.current", return_value=data_asthma_index - ), patch( + ), patch("pyiqvia.asthma.Asthma.current", return_value=data_asthma_index), patch( "pyiqvia.disease.Disease.extended", return_value=data_disease_forecast - ), patch( - "pyiqvia.disease.Disease.current", return_value=data_disease_index - ), patch( + ), patch("pyiqvia.disease.Disease.current", return_value=data_disease_index), patch( "homeassistant.components.iqvia.PLATFORMS", [] ): assert await async_setup_component(hass, DOMAIN, config) diff --git a/tests/components/islamic_prayer_times/__init__.py b/tests/components/islamic_prayer_times/__init__.py index 386d20ab98e..8750461c47f 100644 --- a/tests/components/islamic_prayer_times/__init__.py +++ b/tests/components/islamic_prayer_times/__init__.py @@ -5,43 +5,23 @@ from datetime import datetime import homeassistant.util.dt as dt_util PRAYER_TIMES = { - "Fajr": "06:10", - "Sunrise": "07:25", - "Dhuhr": "12:30", - "Asr": "15:32", - "Maghrib": "17:35", - "Isha": "18:53", - "Midnight": "00:45", -} - -PRAYER_TIMES_TIMESTAMPS = { - "Fajr": datetime(2020, 1, 1, 6, 10, 0, tzinfo=dt_util.UTC), - "Sunrise": datetime(2020, 1, 1, 7, 25, 0, tzinfo=dt_util.UTC), - "Dhuhr": datetime(2020, 1, 1, 12, 30, 0, tzinfo=dt_util.UTC), - "Asr": datetime(2020, 1, 1, 15, 32, 0, tzinfo=dt_util.UTC), - "Maghrib": datetime(2020, 1, 1, 17, 35, 0, tzinfo=dt_util.UTC), - "Isha": datetime(2020, 1, 1, 18, 53, 0, tzinfo=dt_util.UTC), - "Midnight": datetime(2020, 1, 1, 00, 45, 0, tzinfo=dt_util.UTC), + "Fajr": "2020-01-01T06:10:00+00:00", + "Sunrise": "2020-01-01T07:25:00+00:00", + "Dhuhr": "2020-01-01T12:30:00+00:00", + "Asr": "2020-01-01T15:32:00+00:00", + "Maghrib": "2020-01-01T17:35:00+00:00", + "Isha": "2020-01-01T18:53:00+00:00", + "Midnight": "2020-01-01T00:45:00+00:00", } NEW_PRAYER_TIMES = { - "Fajr": "06:00", - "Sunrise": "07:25", - "Dhuhr": "12:30", - "Asr": "15:32", - "Maghrib": "17:45", - "Isha": "18:53", - "Midnight": "00:43", -} - -NEW_PRAYER_TIMES_TIMESTAMPS = { - "Fajr": datetime(2020, 1, 1, 6, 00, 0, tzinfo=dt_util.UTC), - "Sunrise": datetime(2020, 1, 1, 7, 25, 0, tzinfo=dt_util.UTC), - "Dhuhr": datetime(2020, 1, 1, 12, 30, 0, tzinfo=dt_util.UTC), - "Asr": datetime(2020, 1, 1, 15, 32, 0, tzinfo=dt_util.UTC), - "Maghrib": datetime(2020, 1, 1, 17, 45, 0, tzinfo=dt_util.UTC), - "Isha": datetime(2020, 1, 1, 18, 53, 0, tzinfo=dt_util.UTC), - "Midnight": datetime(2020, 1, 1, 00, 43, 0, tzinfo=dt_util.UTC), + "Fajr": "2020-01-02T06:00:00+00:00", + "Sunrise": "2020-01-02T07:25:00+00:00", + "Dhuhr": "2020-01-02T12:30:00+00:00", + "Asr": "2020-01-02T15:32:00+00:00", + "Maghrib": "2020-01-02T17:45:00+00:00", + "Isha": "2020-01-02T18:53:00+00:00", + "Midnight": "2020-01-02T00:43:00+00:00", } NOW = datetime(2020, 1, 1, 00, 00, 0, tzinfo=dt_util.UTC) diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index 6b3b112e042..0c3f19e43fe 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -10,16 +10,12 @@ from homeassistant import config_entries from homeassistant.components import islamic_prayer_times from homeassistant.components.islamic_prayer_times.const import CONF_CALC_METHOD from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +import homeassistant.util.dt as dt_util -from . import ( - NEW_PRAYER_TIMES, - NEW_PRAYER_TIMES_TIMESTAMPS, - NOW, - PRAYER_TIMES, - PRAYER_TIMES_TIMESTAMPS, -) +from . import NEW_PRAYER_TIMES, NOW, PRAYER_TIMES from tests.common import MockConfigEntry, async_fire_time_changed @@ -85,7 +81,6 @@ async def test_unload_entry(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.NOT_LOADED - assert islamic_prayer_times.DOMAIN not in hass.data async def test_options_listener(hass: HomeAssistant) -> None: @@ -96,7 +91,7 @@ async def test_options_listener(hass: HomeAssistant) -> None: with patch( "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", return_value=PRAYER_TIMES, - ) as mock_fetch_prayer_times: + ) as mock_fetch_prayer_times, freeze_time(NOW): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert mock_fetch_prayer_times.call_count == 1 @@ -108,8 +103,8 @@ async def test_options_listener(hass: HomeAssistant) -> None: assert mock_fetch_prayer_times.call_count == 2 -async def test_islamic_prayer_times_timestamp_format(hass: HomeAssistant) -> None: - """Test Islamic prayer times timestamp format.""" +async def test_update_failed(hass: HomeAssistant) -> None: + """Test integrations tries to update after 1 min if update fails.""" entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={}) entry.add_to_hass(hass) @@ -120,33 +115,32 @@ async def test_islamic_prayer_times_timestamp_format(hass: HomeAssistant) -> Non await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.data[islamic_prayer_times.DOMAIN].data == PRAYER_TIMES_TIMESTAMPS - - -async def test_update(hass: HomeAssistant) -> None: - """Test sensors are updated with new prayer times.""" - entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={}) - entry.add_to_hass(hass) + assert entry.state is config_entries.ConfigEntryState.LOADED with patch( "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times" - ) as FetchPrayerTimes, freeze_time(NOW): + ) as FetchPrayerTimes: FetchPrayerTimes.side_effect = [ - PRAYER_TIMES, + InvalidResponseError, NEW_PRAYER_TIMES, ] + midnight_time = dt_util.parse_datetime(PRAYER_TIMES["Midnight"]) + assert midnight_time + future = midnight_time + timedelta(days=1, minutes=1) + with freeze_time(future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + state = hass.states.get("sensor.islamic_prayer_times_fajr_prayer") + assert state.state == STATE_UNAVAILABLE - pt_data = hass.data[islamic_prayer_times.DOMAIN] - assert pt_data.data == PRAYER_TIMES_TIMESTAMPS - - future = pt_data.data["Midnight"] + timedelta(days=1, minutes=1) - - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - assert pt_data.data == NEW_PRAYER_TIMES_TIMESTAMPS + # coordinator tries to update after 1 minute + future = future + timedelta(minutes=1) + with freeze_time(future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + state = hass.states.get("sensor.islamic_prayer_times_fajr_prayer") + assert state.state == "2020-01-02T06:00:00+00:00" @pytest.mark.parametrize( @@ -163,15 +157,16 @@ async def test_update(hass: HomeAssistant) -> None: ], ) async def test_migrate_unique_id( - hass: HomeAssistant, object_id: str, old_unique_id: str + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + object_id: str, + old_unique_id: str, ) -> None: """Test unique id migration.""" entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={}) entry.add_to_hass(hass) - ent_reg = er.async_get(hass) - - entity: er.RegistryEntry = ent_reg.async_get_or_create( + entity: er.RegistryEntry = entity_registry.async_get_or_create( suggested_object_id=object_id, domain=SENSOR_DOMAIN, platform=islamic_prayer_times.DOMAIN, @@ -187,6 +182,6 @@ async def test_migrate_unique_id( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - entity_migrated = ent_reg.async_get(entity.entity_id) + entity_migrated = entity_registry.async_get(entity.entity_id) assert entity_migrated assert entity_migrated.unique_id == f"{entry.entry_id}-{old_unique_id}" diff --git a/tests/components/islamic_prayer_times/test_sensor.py b/tests/components/islamic_prayer_times/test_sensor.py index e7f3759f993..164ac8818fe 100644 --- a/tests/components/islamic_prayer_times/test_sensor.py +++ b/tests/components/islamic_prayer_times/test_sensor.py @@ -6,9 +6,8 @@ import pytest from homeassistant.components.islamic_prayer_times.const import DOMAIN from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util -from . import NOW, PRAYER_TIMES, PRAYER_TIMES_TIMESTAMPS +from . import NOW, PRAYER_TIMES from tests.common import MockConfigEntry @@ -44,7 +43,4 @@ async def test_islamic_prayer_times_sensors( ), freeze_time(NOW): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert ( - hass.states.get(sensor_name).state - == PRAYER_TIMES_TIMESTAMPS[key].astimezone(dt_util.UTC).isoformat() - ) + assert hass.states.get(sensor_name).state == PRAYER_TIMES[key] diff --git a/tests/components/jellyfin/test_media_player.py b/tests/components/jellyfin/test_media_player.py index 64ed41ffdfa..00fe230b31f 100644 --- a/tests/components/jellyfin/test_media_player.py +++ b/tests/components/jellyfin/test_media_player.py @@ -41,14 +41,13 @@ from tests.typing import WebSocketGenerator async def test_media_player( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, mock_jellyfin: MagicMock, mock_api: MagicMock, ) -> None: """Test the Jellyfin media player.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - state = hass.states.get("media_player.jellyfin_device") assert state @@ -97,13 +96,12 @@ async def test_media_player( async def test_media_player_music( hass: HomeAssistant, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, mock_jellyfin: MagicMock, mock_api: MagicMock, ) -> None: """Test the Jellyfin media player.""" - entity_registry = er.async_get(hass) - state = hass.states.get("media_player.jellyfin_device_four") assert state diff --git a/tests/components/jellyfin/test_sensor.py b/tests/components/jellyfin/test_sensor.py index 087be30b70c..733cb795271 100644 --- a/tests/components/jellyfin/test_sensor.py +++ b/tests/components/jellyfin/test_sensor.py @@ -17,13 +17,12 @@ from tests.common import MockConfigEntry async def test_watching( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, mock_jellyfin: MagicMock, ) -> None: """Test the Jellyfin watching sensor.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - state = hass.states.get("sensor.jellyfin_server") assert state assert state.attributes.get(ATTR_DEVICE_CLASS) is None diff --git a/tests/components/jewish_calendar/test_binary_sensor.py b/tests/components/jewish_calendar/test_binary_sensor.py index 4b40519598f..d14ae0faad2 100644 --- a/tests/components/jewish_calendar/test_binary_sensor.py +++ b/tests/components/jewish_calendar/test_binary_sensor.py @@ -169,6 +169,7 @@ MELACHA_TEST_IDS = [ ) async def test_issur_melacha_sensor( hass: HomeAssistant, + entity_registry: er.EntityRegistry, now, candle_lighting, havdalah, @@ -186,8 +187,6 @@ async def test_issur_melacha_sensor( hass.config.latitude = latitude hass.config.longitude = longitude - registry = er.async_get(hass) - with alter_time(test_time): assert await async_setup_component( hass, @@ -208,7 +207,7 @@ async def test_issur_melacha_sensor( hass.states.get("binary_sensor.test_issur_melacha_in_effect").state == result["state"] ) - entity = registry.async_get("binary_sensor.test_issur_melacha_in_effect") + entity = entity_registry.async_get("binary_sensor.test_issur_melacha_in_effect") target_uid = "_".join( map( str, diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index 1aa7fad00d2..0f2912e9de3 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -496,6 +496,7 @@ SHABBAT_TEST_IDS = [ ) async def test_shabbat_times_sensor( hass: HomeAssistant, + entity_registry: er.EntityRegistry, language, now, candle_lighting, @@ -514,8 +515,6 @@ async def test_shabbat_times_sensor( hass.config.latitude = latitude hass.config.longitude = longitude - registry = er.async_get(hass) - with alter_time(test_time): assert await async_setup_component( hass, @@ -552,7 +551,7 @@ async def test_shabbat_times_sensor( result_value ), f"Value for {sensor_type}" - entity = registry.async_get(f"sensor.test_{sensor_type}") + entity = entity_registry.async_get(f"sensor.test_{sensor_type}") target_sensor_type = sensor_type.replace("parshat_hashavua", "weekly_portion") target_uid = "_".join( map( diff --git a/tests/components/jvc_projector/test_init.py b/tests/components/jvc_projector/test_init.py index 0f1ef8b6dcf..ef9de41ca32 100644 --- a/tests/components/jvc_projector/test_init.py +++ b/tests/components/jvc_projector/test_init.py @@ -16,11 +16,11 @@ from tests.common import MockConfigEntry async def test_init( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_device: AsyncMock, mock_integration: MockConfigEntry, ) -> None: """Test initialization.""" - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, MOCK_MAC)}) assert device is not None assert device.identifiers == {(DOMAIN, MOCK_MAC)} diff --git a/tests/components/jvc_projector/test_remote.py b/tests/components/jvc_projector/test_remote.py index 5beccd33e38..5505e160ca7 100644 --- a/tests/components/jvc_projector/test_remote.py +++ b/tests/components/jvc_projector/test_remote.py @@ -21,13 +21,14 @@ ENTITY_ID = "remote.jvc_projector" async def test_entity_state( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_device: MagicMock, mock_integration: MockConfigEntry, ) -> None: """Tests entity state is registered.""" entity = hass.states.get(ENTITY_ID) assert entity - assert er.async_get(hass).async_get(entity.entity_id) + assert entity_registry.async_get(entity.entity_id) async def test_commands( diff --git a/tests/components/kaleidescape/test_init.py b/tests/components/kaleidescape/test_init.py index d0826f4714a..28d90290996 100644 --- a/tests/components/kaleidescape/test_init.py +++ b/tests/components/kaleidescape/test_init.py @@ -47,11 +47,11 @@ async def test_config_entry_not_ready( async def test_device( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_device: AsyncMock, mock_integration: MockConfigEntry, ) -> None: """Test device.""" - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={("kaleidescape", MOCK_SERIAL)} ) diff --git a/tests/components/kaleidescape/test_media_player.py b/tests/components/kaleidescape/test_media_player.py index f38c61d3e73..ad7dcbcaa51 100644 --- a/tests/components/kaleidescape/test_media_player.py +++ b/tests/components/kaleidescape/test_media_player.py @@ -170,11 +170,11 @@ async def test_services( async def test_device( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_device: MagicMock, mock_integration: MockConfigEntry, ) -> None: """Test device attributes.""" - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={("kaleidescape", MOCK_SERIAL)} ) diff --git a/tests/components/kaleidescape/test_sensor.py b/tests/components/kaleidescape/test_sensor.py index 3fbff29e3e9..70406872464 100644 --- a/tests/components/kaleidescape/test_sensor.py +++ b/tests/components/kaleidescape/test_sensor.py @@ -18,12 +18,13 @@ FRIENDLY_NAME = f"Kaleidescape Device {MOCK_SERIAL}" async def test_sensors( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_device: MagicMock, mock_integration: MockConfigEntry, ) -> None: """Test sensors.""" entity = hass.states.get(f"{ENTITY_ID}_media_location") - entry = er.async_get(hass).async_get(f"{ENTITY_ID}_media_location") + entry = entity_registry.async_get(f"{ENTITY_ID}_media_location") assert entity assert entity.state == "none" assert ( @@ -33,7 +34,7 @@ async def test_sensors( assert entry.unique_id == f"{MOCK_SERIAL}-media_location" entity = hass.states.get(f"{ENTITY_ID}_play_status") - entry = er.async_get(hass).async_get(f"{ENTITY_ID}_play_status") + entry = entity_registry.async_get(f"{ENTITY_ID}_play_status") assert entity assert entity.state == "none" assert entity.attributes.get(ATTR_FRIENDLY_NAME) == f"{FRIENDLY_NAME} Play status" diff --git a/tests/components/knx/test_binary_sensor.py b/tests/components/knx/test_binary_sensor.py index 47715433a52..aace7a0224c 100644 --- a/tests/components/knx/test_binary_sensor.py +++ b/tests/components/knx/test_binary_sensor.py @@ -24,7 +24,7 @@ from tests.common import ( async def test_binary_sensor_entity_category( - hass: HomeAssistant, knx: KNXTestKit + hass: HomeAssistant, entity_registry: er.EntityRegistry, knx: KNXTestKit ) -> None: """Test KNX binary sensor entity category.""" await knx.setup_integration( @@ -42,8 +42,7 @@ async def test_binary_sensor_entity_category( await knx.assert_read("1/1/1") await knx.receive_response("1/1/1", True) - registry = er.async_get(hass) - entity = registry.async_get("binary_sensor.test_normal") + entity = entity_registry.async_get("binary_sensor.test_normal") assert entity.entity_category is EntityCategory.DIAGNOSTIC diff --git a/tests/components/knx/test_button.py b/tests/components/knx/test_button.py index 3e8519feb98..a905e66fe5d 100644 --- a/tests/components/knx/test_button.py +++ b/tests/components/knx/test_button.py @@ -130,9 +130,9 @@ async def test_button_invalid( assert len(caplog.messages) == 2 record = caplog.records[0] assert record.levelname == "ERROR" - assert f"Invalid config for [knx]: {error_msg}" in record.message + assert f"Invalid config for 'knx': {error_msg}" in record.message record = caplog.records[1] assert record.levelname == "ERROR" - assert "Setup failed for knx: Invalid config." in record.message + assert "Setup failed for 'knx': Invalid config." in record.message assert hass.states.get("button.test") is None assert hass.data.get(DOMAIN) is None diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 5d42ed79542..0f2d8e56050 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -77,9 +77,9 @@ def patch_file_upload(return_value=FIXTURE_KEYRING, side_effect=None): return_value=return_value, side_effect=side_effect, ), patch( - "pathlib.Path.mkdir" + "pathlib.Path.mkdir", ) as mkdir_mock, patch( - "shutil.move" + "shutil.move", ) as shutil_move_mock: file_upload_mock.return_value.__enter__.return_value = Mock() yield return_value diff --git a/tests/components/kostal_plenticore/conftest.py b/tests/components/kostal_plenticore/conftest.py index 814a46f4a25..a83d9fd5e17 100644 --- a/tests/components/kostal_plenticore/conftest.py +++ b/tests/components/kostal_plenticore/conftest.py @@ -49,24 +49,20 @@ def mock_plenticore() -> Generator[Plenticore, None, None]: plenticore.client.get_version = AsyncMock() plenticore.client.get_version.return_value = VersionData( - { - "api_version": "0.2.0", - "hostname": "scb", - "name": "PUCK RESTful API", - "sw_version": "01.16.05025", - } + api_version="0.2.0", + hostname="scb", + name="PUCK RESTful API", + sw_version="01.16.05025", ) plenticore.client.get_me = AsyncMock() plenticore.client.get_me.return_value = MeData( - { - "locked": False, - "active": True, - "authenticated": True, - "permissions": [], - "anonymous": False, - "role": "USER", - } + locked=False, + active=True, + authenticated=True, + permissions=[], + anonymous=False, + role="USER", ) plenticore.client.get_process_data = AsyncMock() diff --git a/tests/components/kostal_plenticore/test_config_flow.py b/tests/components/kostal_plenticore/test_config_flow.py index 41facfe9c26..8bfe227bfdf 100644 --- a/tests/components/kostal_plenticore/test_config_flow.py +++ b/tests/components/kostal_plenticore/test_config_flow.py @@ -54,7 +54,19 @@ async def test_form_g1( # mock of the context manager instance mock_apiclient.login = AsyncMock() mock_apiclient.get_settings = AsyncMock( - return_value={"scb:network": [SettingsData({"id": "Hostname"})]} + return_value={ + "scb:network": [ + SettingsData( + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Hostname", + type="string", + ), + ] + } ) mock_apiclient.get_setting_values = AsyncMock( # G1 model has the entry id "Hostname" @@ -108,7 +120,19 @@ async def test_form_g2( # mock of the context manager instance mock_apiclient.login = AsyncMock() mock_apiclient.get_settings = AsyncMock( - return_value={"scb:network": [SettingsData({"id": "Network:Hostname"})]} + return_value={ + "scb:network": [ + SettingsData( + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Network:Hostname", + type="string", + ), + ] + } ) mock_apiclient.get_setting_values = AsyncMock( # G1 model has the entry id "Hostname" diff --git a/tests/components/kostal_plenticore/test_diagnostics.py b/tests/components/kostal_plenticore/test_diagnostics.py index d6a57648400..87c8c0e26a8 100644 --- a/tests/components/kostal_plenticore/test_diagnostics.py +++ b/tests/components/kostal_plenticore/test_diagnostics.py @@ -26,15 +26,13 @@ async def test_entry_diagnostics( mock_plenticore.client.get_settings.return_value = { "devices:local": [ SettingsData( - { - "id": "Battery:MinSoc", - "unit": "%", - "default": "None", - "min": 5, - "max": 100, - "type": "byte", - "access": "readwrite", - } + min="5", + max="100", + default=None, + access="readwrite", + unit="%", + id="Battery:MinSoc", + type="byte", ) ] } @@ -56,12 +54,12 @@ async def test_entry_diagnostics( "disabled_by": None, }, "client": { - "version": "Version(api_version=0.2.0, hostname=scb, name=PUCK RESTful API, sw_version=01.16.05025)", - "me": "Me(locked=False, active=True, authenticated=True, permissions=[], anonymous=False, role=USER)", + "version": "api_version='0.2.0' hostname='scb' name='PUCK RESTful API' sw_version='01.16.05025'", + "me": "is_locked=False is_active=True is_authenticated=True permissions=[] is_anonymous=False role='USER'", "available_process_data": {"devices:local": ["HomeGrid_P", "HomePv_P"]}, "available_settings_data": { "devices:local": [ - "SettingsData(id=Battery:MinSoc, unit=%, default=None, min=5, max=100,type=byte, access=readwrite)" + "min='5' max='100' default=None access='readwrite' unit='%' id='Battery:MinSoc' type='byte'" ] }, }, diff --git a/tests/components/kostal_plenticore/test_helper.py b/tests/components/kostal_plenticore/test_helper.py index 61df222fd9e..93550405897 100644 --- a/tests/components/kostal_plenticore/test_helper.py +++ b/tests/components/kostal_plenticore/test_helper.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from pykoplenti import ApiClient, SettingsData +from pykoplenti import ApiClient, ExtendedApiClient, SettingsData import pytest from homeassistant.components.kostal_plenticore.const import DOMAIN @@ -17,10 +17,10 @@ from tests.common import MockConfigEntry def mock_apiclient() -> Generator[ApiClient, None, None]: """Return a mocked ApiClient class.""" with patch( - "homeassistant.components.kostal_plenticore.helper.ApiClient", + "homeassistant.components.kostal_plenticore.helper.ExtendedApiClient", autospec=True, ) as mock_api_class: - apiclient = MagicMock(spec=ApiClient) + apiclient = MagicMock(spec=ExtendedApiClient) apiclient.__aenter__.return_value = apiclient apiclient.__aexit__ = AsyncMock() mock_api_class.return_value = apiclient @@ -34,7 +34,19 @@ async def test_plenticore_async_setup_g1( ) -> None: """Tests the async_setup() method of the Plenticore class for G1 models.""" mock_apiclient.get_settings = AsyncMock( - return_value={"scb:network": [SettingsData({"id": "Hostname"})]} + return_value={ + "scb:network": [ + SettingsData( + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Hostname", + type="string", + ) + ] + } ) mock_apiclient.get_setting_values = AsyncMock( # G1 model has the entry id "Hostname" @@ -74,7 +86,19 @@ async def test_plenticore_async_setup_g2( ) -> None: """Tests the async_setup() method of the Plenticore class for G2 models.""" mock_apiclient.get_settings = AsyncMock( - return_value={"scb:network": [SettingsData({"id": "Network:Hostname"})]} + return_value={ + "scb:network": [ + SettingsData( + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Network:Hostname", + type="string", + ) + ] + } ) mock_apiclient.get_setting_values = AsyncMock( # G1 model has the entry id "Hostname" diff --git a/tests/components/kostal_plenticore/test_number.py b/tests/components/kostal_plenticore/test_number.py index dd5ba7127a8..fc7d9f213fe 100644 --- a/tests/components/kostal_plenticore/test_number.py +++ b/tests/components/kostal_plenticore/test_number.py @@ -23,9 +23,9 @@ from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture def mock_plenticore_client() -> Generator[ApiClient, None, None]: - """Return a patched ApiClient.""" + """Return a patched ExtendedApiClient.""" with patch( - "homeassistant.components.kostal_plenticore.helper.ApiClient", + "homeassistant.components.kostal_plenticore.helper.ExtendedApiClient", autospec=True, ) as plenticore_client_class: yield plenticore_client_class.return_value @@ -41,39 +41,33 @@ def mock_get_setting_values(mock_plenticore_client: ApiClient) -> list: mock_plenticore_client.get_settings.return_value = { "devices:local": [ SettingsData( - { - "default": None, - "min": 5, - "max": 100, - "access": "readwrite", - "unit": "%", - "type": "byte", - "id": "Battery:MinSoc", - } + min="5", + max="100", + default=None, + access="readwrite", + unit="%", + id="Battery:MinSoc", + type="byte", ), SettingsData( - { - "default": None, - "min": 50, - "max": 38000, - "access": "readwrite", - "unit": "W", - "type": "byte", - "id": "Battery:MinHomeComsumption", - } + min="50", + max="38000", + default=None, + access="readwrite", + unit="W", + id="Battery:MinHomeComsumption", + type="byte", ), ], "scb:network": [ SettingsData( - { - "min": "1", - "default": None, - "access": "readwrite", - "unit": None, - "id": "Hostname", - "type": "string", - "max": "63", - } + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Hostname", + type="string", ) ], } @@ -129,15 +123,13 @@ async def test_setup_no_entries( mock_plenticore_client.get_settings.return_value = { "scb:network": [ SettingsData( - { - "min": "1", - "default": None, - "access": "readwrite", - "unit": None, - "id": "Hostname", - "type": "string", - "max": "63", - } + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Hostname", + type="string", ) ], } diff --git a/tests/components/kostal_plenticore/test_select.py b/tests/components/kostal_plenticore/test_select.py index 682e8f72ac8..9af2589af9b 100644 --- a/tests/components/kostal_plenticore/test_select.py +++ b/tests/components/kostal_plenticore/test_select.py @@ -18,8 +18,24 @@ async def test_select_battery_charging_usage_available( mock_plenticore.client.get_settings.return_value = { "devices:local": [ - SettingsData({"id": "Battery:SmartBatteryControl:Enable"}), - SettingsData({"id": "Battery:TimeControl:Enable"}), + SettingsData( + min=None, + max=None, + default=None, + access="readwrite", + unit=None, + id="Battery:SmartBatteryControl:Enable", + type="string", + ), + SettingsData( + min=None, + max=None, + default=None, + access="readwrite", + unit=None, + id="Battery:TimeControl:Enable", + type="string", + ), ] } diff --git a/tests/components/kraken/test_sensor.py b/tests/components/kraken/test_sensor.py index 5ef913ab74b..3ba351a4225 100644 --- a/tests/components/kraken/test_sensor.py +++ b/tests/components/kraken/test_sensor.py @@ -134,7 +134,9 @@ async def test_sensor( async def test_sensors_available_after_restart( - hass: HomeAssistant, freezer: FrozenDateTimeFactory + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, ) -> None: """Test that all sensors are added again after a restart.""" with patch( @@ -153,7 +155,6 @@ async def test_sensors_available_after_restart( ) entry.add_to_hass(hass) - device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, "XBT_USD")}, diff --git a/tests/components/lametric/test_helpers.py b/tests/components/lametric/test_helpers.py index 9a03a4d52cf..a1b824086d2 100644 --- a/tests/components/lametric/test_helpers.py +++ b/tests/components/lametric/test_helpers.py @@ -12,12 +12,11 @@ from tests.common import MockConfigEntry async def test_get_coordinator_by_device_id( hass: HomeAssistant, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, mock_lametric: MagicMock, ) -> None: """Test get LaMetric coordinator by device ID .""" - entity_registry = er.async_get(hass) - with pytest.raises(ValueError, match="Unknown LaMetric device ID: bla"): async_get_coordinator_by_device_id(hass, "bla") diff --git a/tests/components/lametric/test_services.py b/tests/components/lametric/test_services.py index 6a6ff4256a7..9a1258a82bb 100644 --- a/tests/components/lametric/test_services.py +++ b/tests/components/lametric/test_services.py @@ -34,10 +34,10 @@ pytestmark = pytest.mark.usefixtures("init_integration") async def test_service_chart( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_lametric: MagicMock, ) -> None: """Test the LaMetric chart service.""" - entity_registry = er.async_get(hass) entry = entity_registry.async_get("button.frenck_s_lametric_next_app") assert entry @@ -121,10 +121,10 @@ async def test_service_chart( async def test_service_message( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_lametric: MagicMock, ) -> None: """Test the LaMetric message service.""" - entity_registry = er.async_get(hass) entry = entity_registry.async_get("button.frenck_s_lametric_next_app") assert entry diff --git a/tests/components/landisgyr_heat_meter/test_init.py b/tests/components/landisgyr_heat_meter/test_init.py index 46fc07c5eb9..f8615aa77af 100644 --- a/tests/components/landisgyr_heat_meter/test_init.py +++ b/tests/components/landisgyr_heat_meter/test_init.py @@ -39,7 +39,9 @@ async def test_unload_entry(_, hass: HomeAssistant) -> None: @patch(API_HEAT_METER_SERVICE) -async def test_migrate_entry(_, hass: HomeAssistant) -> None: +async def test_migrate_entry( + _, hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test successful migration of entry data from version 1 to 2.""" mock_entry_data = { @@ -59,8 +61,7 @@ async def test_migrate_entry(_, hass: HomeAssistant) -> None: mock_entry.add_to_hass(hass) # Create entity entry to migrate to new unique ID - registry = er.async_get(hass) - registry.async_get_or_create( + entity_registry.async_get_or_create( SENSOR_DOMAIN, LANDISGYR_HEAT_METER_DOMAIN, "landisgyr_heat_meter_987654321_measuring_range_m3ph", @@ -74,5 +75,5 @@ async def test_migrate_entry(_, hass: HomeAssistant) -> None: # Check if entity unique id is migrated successfully assert mock_entry.version == 2 - entity = registry.async_get("sensor.heat_meter_measuring_range") + entity = entity_registry.async_get("sensor.heat_meter_measuring_range") assert entity.unique_id == "12345_measuring_range_m3ph" diff --git a/tests/components/lastfm/test_config_flow.py b/tests/components/lastfm/test_config_flow.py index 07e96afaced..8a2c556a8d0 100644 --- a/tests/components/lastfm/test_config_flow.py +++ b/tests/components/lastfm/test_config_flow.py @@ -11,10 +11,9 @@ from homeassistant.components.lastfm.const import ( DEFAULT_NAME, DOMAIN, ) -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType from . import ( API_KEY, @@ -22,7 +21,6 @@ from . import ( CONF_FRIENDS_DATA, CONF_USER_DATA, USERNAME_1, - USERNAME_2, MockUser, patch_setup_entry, ) @@ -158,45 +156,6 @@ async def test_flow_friends_no_friends( assert len(result["data_schema"].schema[CONF_USERS].config["options"]) == 0 -async def test_import_flow_success(hass: HomeAssistant, default_user: MockUser) -> None: - """Test import flow.""" - with patch("pylast.User", return_value=default_user): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_API_KEY: API_KEY, CONF_USERS: [USERNAME_1, USERNAME_2]}, - ) - await hass.async_block_till_done() - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == "LastFM" - assert result["options"] == { - "api_key": "asdasdasdasdasd", - "main_user": None, - "users": ["testaccount1", "testaccount2"], - } - - -async def test_import_flow_already_exist( - hass: HomeAssistant, - setup_integration: ComponentSetup, - imported_config_entry: MockConfigEntry, - default_user: MockUser, -) -> None: - """Test import of yaml already exist.""" - await setup_integration(imported_config_entry, default_user) - - with patch("pylast.User", return_value=default_user): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=CONF_DATA, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_configured" - - async def test_options_flow( hass: HomeAssistant, setup_integration: ComponentSetup, diff --git a/tests/components/lastfm/test_sensor.py b/tests/components/lastfm/test_sensor.py index f5723215e2a..f33419dd8ea 100644 --- a/tests/components/lastfm/test_sensor.py +++ b/tests/components/lastfm/test_sensor.py @@ -1,39 +1,14 @@ """Tests for the lastfm sensor.""" -from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.lastfm.const import CONF_USERS, DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_API_KEY, CONF_PLATFORM, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component -from . import API_KEY, USERNAME_1, MockUser from .conftest import ComponentSetup from tests.common import MockConfigEntry -LEGACY_CONFIG = { - Platform.SENSOR: [ - {CONF_PLATFORM: DOMAIN, CONF_API_KEY: API_KEY, CONF_USERS: [USERNAME_1]} - ] -} - - -async def test_legacy_migration(hass: HomeAssistant) -> None: - """Test migration from yaml to config flow.""" - with patch("pylast.User", return_value=MockUser()): - assert await async_setup_component(hass, Platform.SENSOR, LEGACY_CONFIG) - await hass.async_block_till_done() - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.LOADED - issue_registry = ir.async_get(hass) - assert len(issue_registry.issues) == 1 - @pytest.mark.parametrize( ("fixture"), diff --git a/tests/components/lcn/test_binary_sensor.py b/tests/components/lcn/test_binary_sensor.py index 70df5af2305..c92a45d7cc9 100644 --- a/tests/components/lcn/test_binary_sensor.py +++ b/tests/components/lcn/test_binary_sensor.py @@ -37,9 +37,10 @@ async def test_entity_state(hass: HomeAssistant, lcn_connection) -> None: assert state -async def test_entity_attributes(hass: HomeAssistant, entry, lcn_connection) -> None: +async def test_entity_attributes( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entry, lcn_connection +) -> None: """Test the attributes of an entity.""" - entity_registry = er.async_get(hass) entity_setpoint1 = entity_registry.async_get(BINARY_SENSOR_LOCKREGULATOR1) assert entity_setpoint1 diff --git a/tests/components/lcn/test_cover.py b/tests/components/lcn/test_cover.py index 74240c900be..4705591e1d3 100644 --- a/tests/components/lcn/test_cover.py +++ b/tests/components/lcn/test_cover.py @@ -38,9 +38,10 @@ async def test_setup_lcn_cover(hass: HomeAssistant, entry, lcn_connection) -> No assert state.state == STATE_OPEN -async def test_entity_attributes(hass: HomeAssistant, entry, lcn_connection) -> None: +async def test_entity_attributes( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entry, lcn_connection +) -> None: """Test the attributes of an entity.""" - entity_registry = er.async_get(hass) entity_outputs = entity_registry.async_get(COVER_OUTPUTS) diff --git a/tests/components/lcn/test_device_trigger.py b/tests/components/lcn/test_device_trigger.py index 47287fbd1d2..59cabb309b0 100644 --- a/tests/components/lcn/test_device_trigger.py +++ b/tests/components/lcn/test_device_trigger.py @@ -49,12 +49,11 @@ async def test_get_triggers_module_device( async def test_get_triggers_non_module_device( - hass: HomeAssistant, entry, lcn_connection + hass: HomeAssistant, device_registry: dr.DeviceRegistry, entry, lcn_connection ) -> None: """Test we get the expected triggers from a LCN non-module device.""" not_included_types = ("transmitter", "transponder", "fingerprint", "send_keys") - device_registry = dr.async_get(hass) host_device = device_registry.async_get_device( identifiers={(DOMAIN, entry.entry_id)} ) diff --git a/tests/components/lcn/test_init.py b/tests/components/lcn/test_init.py index a3b5b01ffbb..fb1d09d91d6 100644 --- a/tests/components/lcn/test_init.py +++ b/tests/components/lcn/test_init.py @@ -48,20 +48,23 @@ async def test_async_setup_multiple_entries(hass: HomeAssistant, entry, entry2) assert not hass.data.get(DOMAIN) -async def test_async_setup_entry_update(hass: HomeAssistant, entry) -> None: +async def test_async_setup_entry_update( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + entry, +) -> None: """Test a successful setup entry if entry with same id already exists.""" # setup first entry entry.source = config_entries.SOURCE_IMPORT entry.add_to_hass(hass) # create dummy entity for LCN platform as an orphan - entity_registry = er.async_get(hass) dummy_entity = entity_registry.async_get_or_create( "switch", DOMAIN, "dummy", config_entry=entry ) # create dummy device for LCN platform as an orphan - device_registry = dr.async_get(hass) dummy_device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, entry.entry_id, 0, 7, False)}, diff --git a/tests/components/lcn/test_light.py b/tests/components/lcn/test_light.py index 73827ad38bb..7f23c1e6214 100644 --- a/tests/components/lcn/test_light.py +++ b/tests/components/lcn/test_light.py @@ -58,10 +58,10 @@ async def test_entity_state(hass: HomeAssistant, lcn_connection) -> None: assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] -async def test_entity_attributes(hass: HomeAssistant, entry, lcn_connection) -> None: +async def test_entity_attributes( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entry, lcn_connection +) -> None: """Test the attributes of an entity.""" - entity_registry = er.async_get(hass) - entity_output = entity_registry.async_get(LIGHT_OUTPUT1) assert entity_output diff --git a/tests/components/lcn/test_sensor.py b/tests/components/lcn/test_sensor.py index 116ab62854d..b46de397255 100644 --- a/tests/components/lcn/test_sensor.py +++ b/tests/components/lcn/test_sensor.py @@ -49,9 +49,10 @@ async def test_entity_state(hass: HomeAssistant, lcn_connection) -> None: assert state -async def test_entity_attributes(hass: HomeAssistant, entry, lcn_connection) -> None: +async def test_entity_attributes( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entry, lcn_connection +) -> None: """Test the attributes of an entity.""" - entity_registry = er.async_get(hass) entity_var1 = entity_registry.async_get(SENSOR_VAR1) assert entity_var1 diff --git a/tests/components/lcn/test_switch.py b/tests/components/lcn/test_switch.py index 44a9e410fe3..a83d45c0889 100644 --- a/tests/components/lcn/test_switch.py +++ b/tests/components/lcn/test_switch.py @@ -39,9 +39,10 @@ async def test_setup_lcn_switch(hass: HomeAssistant, lcn_connection) -> None: assert state.state == STATE_OFF -async def test_entity_attributes(hass: HomeAssistant, entry, lcn_connection) -> None: +async def test_entity_attributes( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entry, lcn_connection +) -> None: """Test the attributes of an entity.""" - entity_registry = er.async_get(hass) entity_output = entity_registry.async_get(SWITCH_OUTPUT1) diff --git a/tests/components/lidarr/test_init.py b/tests/components/lidarr/test_init.py index 5d6961e57c3..ce3a8536b2f 100644 --- a/tests/components/lidarr/test_init.py +++ b/tests/components/lidarr/test_init.py @@ -45,12 +45,14 @@ async def test_async_setup_entry_auth_failed( async def test_device_info( - hass: HomeAssistant, setup_integration: ComponentSetup, connection + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + setup_integration: ComponentSetup, + connection, ) -> None: """Test device info.""" await setup_integration() entry = hass.config_entries.async_entries(DOMAIN)[0] - device_registry = dr.async_get(hass) await hass.async_block_till_done() device = device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) diff --git a/tests/components/lifx/test_binary_sensor.py b/tests/components/lifx/test_binary_sensor.py index d71a7eeaf0b..9fa065f3632 100644 --- a/tests/components/lifx/test_binary_sensor.py +++ b/tests/components/lifx/test_binary_sensor.py @@ -31,7 +31,9 @@ from . import ( from tests.common import MockConfigEntry, async_fire_time_changed -async def test_hev_cycle_state(hass: HomeAssistant) -> None: +async def test_hev_cycle_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test HEV cycle state binary sensor.""" config_entry = MockConfigEntry( domain=lifx.DOMAIN, @@ -48,7 +50,6 @@ async def test_hev_cycle_state(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "binary_sensor.my_bulb_clean_cycle" - entity_registry = er.async_get(hass) state = hass.states.get(entity_id) assert state diff --git a/tests/components/lifx/test_button.py b/tests/components/lifx/test_button.py index d527229fe78..1fd4da4531e 100644 --- a/tests/components/lifx/test_button.py +++ b/tests/components/lifx/test_button.py @@ -31,7 +31,9 @@ def mock_lifx_coordinator_sleep(): yield -async def test_button_restart(hass: HomeAssistant) -> None: +async def test_button_restart( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that a bulb can be restarted.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -50,7 +52,6 @@ async def test_button_restart(hass: HomeAssistant) -> None: unique_id = f"{SERIAL}_restart" entity_id = "button.my_bulb_restart" - entity_registry = er.async_get(hass) entity = entity_registry.async_get(entity_id) assert entity assert not entity.disabled @@ -63,7 +64,9 @@ async def test_button_restart(hass: HomeAssistant) -> None: bulb.set_reboot.assert_called_once() -async def test_button_identify(hass: HomeAssistant) -> None: +async def test_button_identify( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that a bulb can be identified.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -82,7 +85,6 @@ async def test_button_identify(hass: HomeAssistant) -> None: unique_id = f"{SERIAL}_identify" entity_id = "button.my_bulb_identify" - entity_registry = er.async_get(hass) entity = entity_registry.async_get(entity_id) assert entity assert not entity.disabled diff --git a/tests/components/lifx/test_config_flow.py b/tests/components/lifx/test_config_flow.py index 1b7da4f864a..70284106166 100644 --- a/tests/components/lifx/test_config_flow.py +++ b/tests/components/lifx/test_config_flow.py @@ -536,7 +536,11 @@ async def test_refuse_relays(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} -async def test_suggested_area(hass: HomeAssistant) -> None: +async def test_suggested_area( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test suggested area is populated from lifx group label.""" class MockLifxCommandGetGroup: @@ -567,10 +571,8 @@ async def test_suggested_area(hass: HomeAssistant) -> None: await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() - entity_registry = er.async_get(hass) entity_id = "light.my_bulb" entity = entity_registry.async_get(entity_id) - device_registry = dr.async_get(hass) device = device_registry.async_get(entity.device_id) assert device.suggested_area == "My LIFX Group" diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index 70a5a89a3ae..887e622b5cc 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -81,7 +81,11 @@ def patch_lifx_state_settle_delay(): yield -async def test_light_unique_id(hass: HomeAssistant) -> None: +async def test_light_unique_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test a light unique id.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "1.2.3.4"}, unique_id=SERIAL @@ -95,17 +99,19 @@ async def test_light_unique_id(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "light.my_bulb" - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == SERIAL - device_registry = dr.async_get(hass) device = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, SERIAL)} ) assert device.identifiers == {(DOMAIN, SERIAL)} -async def test_light_unique_id_new_firmware(hass: HomeAssistant) -> None: +async def test_light_unique_id_new_firmware( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test a light unique id with newer firmware.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "1.2.3.4"}, unique_id=SERIAL @@ -119,9 +125,7 @@ async def test_light_unique_id_new_firmware(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "light.my_bulb" - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == SERIAL - device_registry = dr.async_get(hass) device = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)}, ) @@ -1115,7 +1119,9 @@ async def test_white_bulb(hass: HomeAssistant) -> None: bulb.set_color.reset_mock() -async def test_config_zoned_light_strip_fails(hass: HomeAssistant) -> None: +async def test_config_zoned_light_strip_fails( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test we handle failure to update zones.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=SERIAL @@ -1144,7 +1150,6 @@ async def test_config_zoned_light_strip_fails(hass: HomeAssistant) -> None: with _patch_discovery(device=light_strip), _patch_device(device=light_strip): await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) await hass.async_block_till_done() - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == SERIAL assert hass.states.get(entity_id).state == STATE_OFF @@ -1153,7 +1158,9 @@ async def test_config_zoned_light_strip_fails(hass: HomeAssistant) -> None: assert hass.states.get(entity_id).state == STATE_UNAVAILABLE -async def test_legacy_zoned_light_strip(hass: HomeAssistant) -> None: +async def test_legacy_zoned_light_strip( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test we handle failure to update zones.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=SERIAL @@ -1183,7 +1190,6 @@ async def test_legacy_zoned_light_strip(hass: HomeAssistant) -> None: with _patch_discovery(device=light_strip), _patch_device(device=light_strip): await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) await hass.async_block_till_done() - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == SERIAL assert hass.states.get(entity_id).state == STATE_OFF # 1 to get the number of zones @@ -1197,7 +1203,9 @@ async def test_legacy_zoned_light_strip(hass: HomeAssistant) -> None: assert get_color_zones_mock.call_count == 5 -async def test_white_light_fails(hass: HomeAssistant) -> None: +async def test_white_light_fails( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test we handle failure to power on off.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=SERIAL @@ -1211,7 +1219,6 @@ async def test_white_light_fails(hass: HomeAssistant) -> None: with _patch_discovery(device=bulb), _patch_device(device=bulb): await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) await hass.async_block_till_done() - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == SERIAL assert hass.states.get(entity_id).state == STATE_OFF with pytest.raises(HomeAssistantError): diff --git a/tests/components/lifx/test_select.py b/tests/components/lifx/test_select.py index aa705418d55..529925be726 100644 --- a/tests/components/lifx/test_select.py +++ b/tests/components/lifx/test_select.py @@ -25,7 +25,9 @@ from . import ( from tests.common import MockConfigEntry, async_fire_time_changed -async def test_theme_select(hass: HomeAssistant) -> None: +async def test_theme_select( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test selecting a theme.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -46,7 +48,6 @@ async def test_theme_select(hass: HomeAssistant) -> None: entity_id = "select.my_bulb_theme" - entity_registry = er.async_get(hass) entity = entity_registry.async_get(entity_id) assert entity assert not entity.disabled @@ -62,7 +63,9 @@ async def test_theme_select(hass: HomeAssistant) -> None: bulb.set_extended_color_zones.reset_mock() -async def test_infrared_brightness(hass: HomeAssistant) -> None: +async def test_infrared_brightness( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test getting and setting infrared brightness.""" config_entry = MockConfigEntry( @@ -82,7 +85,6 @@ async def test_infrared_brightness(hass: HomeAssistant) -> None: unique_id = f"{SERIAL}_infrared_brightness" entity_id = "select.my_bulb_infrared_brightness" - entity_registry = er.async_get(hass) entity = entity_registry.async_get(entity_id) assert entity assert not entity.disabled diff --git a/tests/components/lifx/test_sensor.py b/tests/components/lifx/test_sensor.py index 5fe69c8dabc..e27bc0de3a8 100644 --- a/tests/components/lifx/test_sensor.py +++ b/tests/components/lifx/test_sensor.py @@ -31,7 +31,9 @@ from . import ( from tests.common import MockConfigEntry, async_fire_time_changed -async def test_rssi_sensor(hass: HomeAssistant) -> None: +async def test_rssi_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test LIFX RSSI sensor entity.""" config_entry = MockConfigEntry( @@ -49,7 +51,6 @@ async def test_rssi_sensor(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "sensor.my_bulb_rssi" - entity_registry = er.async_get(hass) entry = entity_registry.entities.get(entity_id) assert entry @@ -82,7 +83,9 @@ async def test_rssi_sensor(hass: HomeAssistant) -> None: assert rssi.attributes["state_class"] == SensorStateClass.MEASUREMENT -async def test_rssi_sensor_old_firmware(hass: HomeAssistant) -> None: +async def test_rssi_sensor_old_firmware( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test LIFX RSSI sensor entity.""" config_entry = MockConfigEntry( @@ -100,7 +103,6 @@ async def test_rssi_sensor_old_firmware(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "sensor.my_bulb_rssi" - entity_registry = er.async_get(hass) entry = entity_registry.entities.get(entity_id) assert entry diff --git a/tests/components/light/test_reproduce_state.py b/tests/components/light/test_reproduce_state.py index 816bde430e7..65b83aa0269 100644 --- a/tests/components/light/test_reproduce_state.py +++ b/tests/components/light/test_reproduce_state.py @@ -2,35 +2,24 @@ import pytest from homeassistant.components import light -from homeassistant.components.light.reproduce_state import DEPRECATION_WARNING from homeassistant.core import HomeAssistant, State from homeassistant.helpers.state import async_reproduce_state from tests.common import async_mock_service VALID_BRIGHTNESS = {"brightness": 180} -VALID_FLASH = {"flash": "short"} VALID_EFFECT = {"effect": "random"} -VALID_TRANSITION = {"transition": 15} -VALID_COLOR_NAME = {"color_name": "red"} VALID_COLOR_TEMP = {"color_temp": 240} VALID_HS_COLOR = {"hs_color": (345, 75)} -VALID_KELVIN = {"kelvin": 4000} -VALID_PROFILE = {"profile": "relax"} VALID_RGB_COLOR = {"rgb_color": (255, 63, 111)} VALID_RGBW_COLOR = {"rgbw_color": (255, 63, 111, 10)} VALID_RGBWW_COLOR = {"rgbww_color": (255, 63, 111, 10, 20)} VALID_XY_COLOR = {"xy_color": (0.59, 0.274)} NONE_BRIGHTNESS = {"brightness": None} -NONE_FLASH = {"flash": None} NONE_EFFECT = {"effect": None} -NONE_TRANSITION = {"transition": None} -NONE_COLOR_NAME = {"color_name": None} NONE_COLOR_TEMP = {"color_temp": None} NONE_HS_COLOR = {"hs_color": None} -NONE_KELVIN = {"kelvin": None} -NONE_PROFILE = {"profile": None} NONE_RGB_COLOR = {"rgb_color": None} NONE_RGBW_COLOR = {"rgbw_color": None} NONE_RGBWW_COLOR = {"rgbww_color": None} @@ -43,14 +32,9 @@ async def test_reproducing_states( """Test reproducing Light 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_flash", "on", VALID_FLASH) hass.states.async_set("light.entity_effect", "on", VALID_EFFECT) - hass.states.async_set("light.entity_trans", "on", VALID_TRANSITION) - hass.states.async_set("light.entity_name", "on", VALID_COLOR_NAME) hass.states.async_set("light.entity_temp", "on", VALID_COLOR_TEMP) hass.states.async_set("light.entity_hs", "on", VALID_HS_COLOR) - hass.states.async_set("light.entity_kelvin", "on", VALID_KELVIN) - hass.states.async_set("light.entity_profile", "on", VALID_PROFILE) hass.states.async_set("light.entity_rgb", "on", VALID_RGB_COLOR) hass.states.async_set("light.entity_xy", "on", VALID_XY_COLOR) @@ -63,14 +47,9 @@ async def test_reproducing_states( [ State("light.entity_off", "off"), State("light.entity_bright", "on", VALID_BRIGHTNESS), - State("light.entity_flash", "on", VALID_FLASH), State("light.entity_effect", "on", VALID_EFFECT), - State("light.entity_trans", "on", VALID_TRANSITION), - State("light.entity_name", "on", VALID_COLOR_NAME), State("light.entity_temp", "on", VALID_COLOR_TEMP), State("light.entity_hs", "on", VALID_HS_COLOR), - State("light.entity_kelvin", "on", VALID_KELVIN), - State("light.entity_profile", "on", VALID_PROFILE), State("light.entity_rgb", "on", VALID_RGB_COLOR), State("light.entity_xy", "on", VALID_XY_COLOR), ], @@ -92,20 +71,15 @@ async def test_reproducing_states( [ State("light.entity_xy", "off"), State("light.entity_off", "on", VALID_BRIGHTNESS), - State("light.entity_bright", "on", VALID_FLASH), - State("light.entity_flash", "on", VALID_EFFECT), - State("light.entity_effect", "on", VALID_TRANSITION), - State("light.entity_trans", "on", VALID_COLOR_NAME), - State("light.entity_name", "on", VALID_COLOR_TEMP), + State("light.entity_bright", "on", VALID_EFFECT), + State("light.entity_effect", "on", VALID_COLOR_TEMP), State("light.entity_temp", "on", VALID_HS_COLOR), - State("light.entity_hs", "on", VALID_KELVIN), - State("light.entity_kelvin", "on", VALID_PROFILE), - State("light.entity_profile", "on", VALID_RGB_COLOR), + State("light.entity_hs", "on", VALID_RGB_COLOR), State("light.entity_rgb", "on", VALID_XY_COLOR), ], ) - assert len(turn_on_calls) == 11 + assert len(turn_on_calls) == 6 expected_calls = [] @@ -113,42 +87,22 @@ async def test_reproducing_states( expected_off["entity_id"] = "light.entity_off" expected_calls.append(expected_off) - expected_bright = dict(VALID_FLASH) + expected_bright = dict(VALID_EFFECT) expected_bright["entity_id"] = "light.entity_bright" expected_calls.append(expected_bright) - expected_flash = dict(VALID_EFFECT) - expected_flash["entity_id"] = "light.entity_flash" - expected_calls.append(expected_flash) - - expected_effect = dict(VALID_TRANSITION) + expected_effect = dict(VALID_COLOR_TEMP) expected_effect["entity_id"] = "light.entity_effect" expected_calls.append(expected_effect) - expected_trans = dict(VALID_COLOR_NAME) - expected_trans["entity_id"] = "light.entity_trans" - expected_calls.append(expected_trans) - - expected_name = dict(VALID_COLOR_TEMP) - expected_name["entity_id"] = "light.entity_name" - expected_calls.append(expected_name) - expected_temp = dict(VALID_HS_COLOR) expected_temp["entity_id"] = "light.entity_temp" expected_calls.append(expected_temp) - expected_hs = dict(VALID_KELVIN) + expected_hs = dict(VALID_RGB_COLOR) expected_hs["entity_id"] = "light.entity_hs" expected_calls.append(expected_hs) - expected_kelvin = dict(VALID_PROFILE) - expected_kelvin["entity_id"] = "light.entity_kelvin" - expected_calls.append(expected_kelvin) - - expected_profile = dict(VALID_RGB_COLOR) - expected_profile["entity_id"] = "light.entity_profile" - expected_calls.append(expected_profile) - expected_rgb = dict(VALID_XY_COLOR) expected_rgb["entity_id"] = "light.entity_rgb" expected_calls.append(expected_rgb) @@ -191,10 +145,8 @@ 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_NAME, **VALID_COLOR_TEMP, **VALID_HS_COLOR, - **VALID_KELVIN, **VALID_RGB_COLOR, **VALID_RGBW_COLOR, **VALID_RGBWW_COLOR, @@ -240,31 +192,13 @@ async def test_filter_color_modes( assert len(turn_on_calls) == 1 -async def test_deprecation_warning( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test deprecation warning.""" - hass.states.async_set("light.entity_off", "off", {}) - turn_on_calls = async_mock_service(hass, "light", "turn_on") - await async_reproduce_state( - hass, [State("light.entity_off", "on", {"brightness_pct": 80})] - ) - assert len(turn_on_calls) == 1 - assert DEPRECATION_WARNING % ["brightness_pct"] in caplog.text - - @pytest.mark.parametrize( "saved_state", ( NONE_BRIGHTNESS, - NONE_FLASH, NONE_EFFECT, - NONE_TRANSITION, - NONE_COLOR_NAME, NONE_COLOR_TEMP, NONE_HS_COLOR, - NONE_KELVIN, - NONE_PROFILE, NONE_RGB_COLOR, NONE_RGBW_COLOR, NONE_RGBWW_COLOR, diff --git a/tests/components/linear_garage_door/__init__.py b/tests/components/linear_garage_door/__init__.py new file mode 100644 index 00000000000..e5abc6c943c --- /dev/null +++ b/tests/components/linear_garage_door/__init__.py @@ -0,0 +1 @@ +"""Tests for the Linear Garage Door integration.""" diff --git a/tests/components/linear_garage_door/test_config_flow.py b/tests/components/linear_garage_door/test_config_flow.py new file mode 100644 index 00000000000..64664745c54 --- /dev/null +++ b/tests/components/linear_garage_door/test_config_flow.py @@ -0,0 +1,163 @@ +"""Test the Linear Garage Door config flow.""" + +from unittest.mock import patch + +from linear_garage_door.errors import InvalidLoginError + +from homeassistant import config_entries +from homeassistant.components.linear_garage_door.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .util import async_init_integration + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.login", + return_value=True, + ), patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.get_sites", + return_value=[{"id": "test-site-id", "name": "test-site-name"}], + ), patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.close", + return_value=None, + ), patch( + "uuid.uuid4", + return_value="test-uuid", + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "email": "test-email", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.linear_garage_door.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {"site": "test-site-id"} + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "test-site-name" + assert result3["data"] == { + "email": "test-email", + "password": "test-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth(hass: HomeAssistant) -> None: + """Test reauthentication.""" + + entry = await async_init_integration(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "title_placeholders": {"name": entry.title}, + "unique_id": entry.unique_id, + }, + data=entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.login", + return_value=True, + ), patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.get_sites", + return_value=[{"id": "test-site-id", "name": "test-site-name"}], + ), patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.close", + return_value=None, + ), patch( + "uuid.uuid4", + return_value="test-uuid", + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "email": "new-email", + "password": "new-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + entries = hass.config_entries.async_entries() + assert len(entries) == 1 + assert entries[0].data == { + "email": "new-email", + "password": "new-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + } + + +async def test_form_invalid_login(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.login", + side_effect=InvalidLoginError, + ), patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.close", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "email": "test-email", + "password": "test-password", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_exception(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + with patch( + "homeassistant.components.linear_garage_door.config_flow.Linear.login", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "email": "test-email", + "password": "test-password", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/linear_garage_door/test_coordinator.py b/tests/components/linear_garage_door/test_coordinator.py new file mode 100644 index 00000000000..fc3087db354 --- /dev/null +++ b/tests/components/linear_garage_door/test_coordinator.py @@ -0,0 +1,99 @@ +"""Test data update coordinator for Linear Garage Door.""" + +from unittest.mock import patch + +from linear_garage_door.errors import InvalidLoginError, ResponseError + +from homeassistant.components.linear_garage_door.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_invalid_password( + hass: HomeAssistant, +) -> None: + """Test invalid password.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "email": "test-email", + "password": "test-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + }, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.login", + side_effect=InvalidLoginError( + "Login provided is invalid, please check the email and password" + ), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state == ConfigEntryState.SETUP_ERROR + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert flows + assert len(flows) == 1 + assert flows[0]["context"]["source"] == "reauth" + + +async def test_response_error(hass: HomeAssistant) -> None: + """Test response error.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "email": "test-email", + "password": "test-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + }, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.login", + side_effect=ResponseError, + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state == ConfigEntryState.SETUP_RETRY + + +async def test_invalid_login( + hass: HomeAssistant, +) -> None: + """Test invalid login.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "email": "test-email", + "password": "test-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + }, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.login", + side_effect=InvalidLoginError("Some other error"), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/linear_garage_door/test_cover.py b/tests/components/linear_garage_door/test_cover.py new file mode 100644 index 00000000000..428411d39e0 --- /dev/null +++ b/tests/components/linear_garage_door/test_cover.py @@ -0,0 +1,187 @@ +"""Test Linear Garage Door cover.""" + +from datetime import timedelta +from unittest.mock import patch + +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.components.linear_garage_door.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util + +from .util import async_init_integration + +from tests.common import async_fire_time_changed + + +async def test_data(hass: HomeAssistant) -> None: + """Test that data gets parsed and returned appropriately.""" + + await async_init_integration(hass) + + assert hass.data[DOMAIN] + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state == ConfigEntryState.LOADED + assert hass.states.get("cover.test_garage_1").state == STATE_OPEN + assert hass.states.get("cover.test_garage_2").state == STATE_CLOSED + assert hass.states.get("cover.test_garage_3").state == STATE_OPENING + assert hass.states.get("cover.test_garage_4").state == STATE_CLOSING + + +async def test_open_cover(hass: HomeAssistant) -> None: + """Test that opening the cover works as intended.""" + + await async_init_integration(hass) + + with patch( + "homeassistant.components.linear_garage_door.cover.Linear.operate_device" + ) as operate_device: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_1"}, + blocking=True, + ) + + assert operate_device.call_count == 0 + + with patch( + "homeassistant.components.linear_garage_door.cover.Linear.login", + return_value=True, + ), patch( + "homeassistant.components.linear_garage_door.cover.Linear.operate_device", + return_value=None, + ) as operate_device, patch( + "homeassistant.components.linear_garage_door.cover.Linear.close", + return_value=True, + ): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_2"}, + blocking=True, + ) + + assert operate_device.call_count == 1 + with patch( + "homeassistant.components.linear_garage_door.cover.Linear.login", + return_value=True, + ), patch( + "homeassistant.components.linear_garage_door.cover.Linear.get_devices", + return_value=[ + {"id": "test1", "name": "Test Garage 1", "subdevices": ["GDO", "Light"]}, + {"id": "test2", "name": "Test Garage 2", "subdevices": ["GDO", "Light"]}, + ], + ), patch( + "homeassistant.components.linear_garage_door.cover.Linear.get_device_state", + side_effect=lambda id: { + "test1": { + "GDO": {"Open_B": "true", "Open_P": "100"}, + "Light": {"On_B": "true", "On_P": "100"}, + }, + "test2": { + "GDO": {"Open_B": "false", "Opening_P": "0"}, + "Light": {"On_B": "false", "On_P": "0"}, + }, + "test3": { + "GDO": {"Open_B": "false", "Opening_P": "0"}, + "Light": {"On_B": "false", "On_P": "0"}, + }, + "test4": { + "GDO": {"Open_B": "true", "Opening_P": "100"}, + "Light": {"On_B": "true", "On_P": "100"}, + }, + }[id], + ), patch( + "homeassistant.components.linear_garage_door.cover.Linear.close", + return_value=True, + ): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + + assert hass.states.get("cover.test_garage_2").state == STATE_OPENING + + +async def test_close_cover(hass: HomeAssistant) -> None: + """Test that closing the cover works as intended.""" + + await async_init_integration(hass) + + with patch( + "homeassistant.components.linear_garage_door.cover.Linear.operate_device" + ) as operate_device: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_2"}, + blocking=True, + ) + + assert operate_device.call_count == 0 + + with patch( + "homeassistant.components.linear_garage_door.cover.Linear.login", + return_value=True, + ), patch( + "homeassistant.components.linear_garage_door.cover.Linear.operate_device", + return_value=None, + ) as operate_device, patch( + "homeassistant.components.linear_garage_door.cover.Linear.close", + return_value=True, + ): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_1"}, + blocking=True, + ) + + assert operate_device.call_count == 1 + with patch( + "homeassistant.components.linear_garage_door.cover.Linear.login", + return_value=True, + ), patch( + "homeassistant.components.linear_garage_door.cover.Linear.get_devices", + return_value=[ + {"id": "test1", "name": "Test Garage 1", "subdevices": ["GDO", "Light"]}, + {"id": "test2", "name": "Test Garage 2", "subdevices": ["GDO", "Light"]}, + ], + ), patch( + "homeassistant.components.linear_garage_door.cover.Linear.get_device_state", + side_effect=lambda id: { + "test1": { + "GDO": {"Open_B": "true", "Opening_P": "100"}, + "Light": {"On_B": "true", "On_P": "100"}, + }, + "test2": { + "GDO": {"Open_B": "false", "Open_P": "0"}, + "Light": {"On_B": "false", "On_P": "0"}, + }, + "test3": { + "GDO": {"Open_B": "false", "Opening_P": "0"}, + "Light": {"On_B": "false", "On_P": "0"}, + }, + "test4": { + "GDO": {"Open_B": "true", "Opening_P": "100"}, + "Light": {"On_B": "true", "On_P": "100"}, + }, + }[id], + ), patch( + "homeassistant.components.linear_garage_door.cover.Linear.close", + return_value=True, + ): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + + assert hass.states.get("cover.test_garage_1").state == STATE_CLOSING diff --git a/tests/components/linear_garage_door/test_diagnostics.py b/tests/components/linear_garage_door/test_diagnostics.py new file mode 100644 index 00000000000..0650196d619 --- /dev/null +++ b/tests/components/linear_garage_door/test_diagnostics.py @@ -0,0 +1,53 @@ +"""Test diagnostics of Linear Garage Door.""" + +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + +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 +) -> None: + """Test config entry diagnostics.""" + entry = await async_init_integration(hass) + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert result["entry"]["data"] == { + "email": "**REDACTED**", + "password": "**REDACTED**", + "site_id": "test-site-id", + "device_id": "test-uuid", + } + assert result["coordinator_data"] == { + "test1": { + "name": "Test Garage 1", + "subdevices": { + "GDO": {"Open_B": "true", "Open_P": "100"}, + "Light": {"On_B": "true", "On_P": "100"}, + }, + }, + "test2": { + "name": "Test Garage 2", + "subdevices": { + "GDO": {"Open_B": "false", "Open_P": "0"}, + "Light": {"On_B": "false", "On_P": "0"}, + }, + }, + "test3": { + "name": "Test Garage 3", + "subdevices": { + "GDO": {"Open_B": "false", "Opening_P": "0"}, + "Light": {"On_B": "false", "On_P": "0"}, + }, + }, + "test4": { + "name": "Test Garage 4", + "subdevices": { + "GDO": {"Open_B": "true", "Opening_P": "100"}, + "Light": {"On_B": "true", "On_P": "100"}, + }, + }, + } diff --git a/tests/components/linear_garage_door/test_init.py b/tests/components/linear_garage_door/test_init.py new file mode 100644 index 00000000000..e8d76770050 --- /dev/null +++ b/tests/components/linear_garage_door/test_init.py @@ -0,0 +1,59 @@ +"""Test Linear Garage Door init.""" + +from unittest.mock import patch + +from homeassistant.components.linear_garage_door.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test the unload entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "email": "test-email", + "password": "test-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + }, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.login", + return_value=True, + ), patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.get_devices", + return_value=[ + {"id": "test", "name": "Test Garage", "subdevices": ["GDO", "Light"]} + ], + ), patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.get_device_state", + return_value={ + "GDO": {"Open_B": "true", "Open_P": "100"}, + "Light": {"On_B": "true", "On_P": "10"}, + }, + ), patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.close", + return_value=True, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.data[DOMAIN] + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state == ConfigEntryState.LOADED + + with patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.close", + return_value=True, + ): + await hass.config_entries.async_unload(entries[0].entry_id) + await hass.async_block_till_done() + assert entries[0].state == ConfigEntryState.NOT_LOADED diff --git a/tests/components/linear_garage_door/util.py b/tests/components/linear_garage_door/util.py new file mode 100644 index 00000000000..d8348b9bb64 --- /dev/null +++ b/tests/components/linear_garage_door/util.py @@ -0,0 +1,62 @@ +"""Utilities for Linear Garage Door testing.""" + +from unittest.mock import patch + +from homeassistant.components.linear_garage_door.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def async_init_integration(hass: HomeAssistant) -> MockConfigEntry: + """Initialize mock integration.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "email": "test-email", + "password": "test-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + }, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.login", + return_value=True, + ), patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.get_devices", + return_value=[ + {"id": "test1", "name": "Test Garage 1", "subdevices": ["GDO", "Light"]}, + {"id": "test2", "name": "Test Garage 2", "subdevices": ["GDO", "Light"]}, + {"id": "test3", "name": "Test Garage 3", "subdevices": ["GDO", "Light"]}, + {"id": "test4", "name": "Test Garage 4", "subdevices": ["GDO", "Light"]}, + ], + ), patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.get_device_state", + side_effect=lambda id: { + "test1": { + "GDO": {"Open_B": "true", "Open_P": "100"}, + "Light": {"On_B": "true", "On_P": "100"}, + }, + "test2": { + "GDO": {"Open_B": "false", "Open_P": "0"}, + "Light": {"On_B": "false", "On_P": "0"}, + }, + "test3": { + "GDO": {"Open_B": "false", "Opening_P": "0"}, + "Light": {"On_B": "false", "On_P": "0"}, + }, + "test4": { + "GDO": {"Open_B": "true", "Opening_P": "100"}, + "Light": {"On_B": "true", "On_P": "100"}, + }, + }[id], + ), patch( + "homeassistant.components.linear_garage_door.coordinator.Linear.close", + return_value=True, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/litejet/test_scene.py b/tests/components/litejet/test_scene.py index d1316d81bbe..76c1556f66d 100644 --- a/tests/components/litejet/test_scene.py +++ b/tests/components/litejet/test_scene.py @@ -17,16 +17,16 @@ ENTITY_OTHER_SCENE = "scene.litejet_mock_scene_2" ENTITY_OTHER_SCENE_NUMBER = 2 -async def test_disabled_by_default(hass: HomeAssistant, mock_litejet) -> None: +async def test_disabled_by_default( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_litejet +) -> None: """Test the scene is disabled by default.""" await async_init_integration(hass) - registry = er.async_get(hass) - state = hass.states.get(ENTITY_SCENE) assert state is None - entry = registry.async_get(ENTITY_SCENE) + entry = entity_registry.async_get(ENTITY_SCENE) assert entry assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/litterrobot/test_button.py b/tests/components/litterrobot/test_button.py index a17c0439824..9a4145dd224 100644 --- a/tests/components/litterrobot/test_button.py +++ b/tests/components/litterrobot/test_button.py @@ -13,10 +13,11 @@ from .conftest import setup_integration BUTTON_ENTITY = "button.test_reset_waste_drawer" -async def test_button(hass: HomeAssistant, mock_account: MagicMock) -> None: +async def test_button( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_account: MagicMock +) -> None: """Test the creation and values of the Litter-Robot button.""" await setup_integration(hass, mock_account, BUTTON_DOMAIN) - entity_registry = er.async_get(hass) state = hass.states.get(BUTTON_ENTITY) assert state diff --git a/tests/components/litterrobot/test_init.py b/tests/components/litterrobot/test_init.py index 170d6313029..25c47ee4945 100644 --- a/tests/components/litterrobot/test_init.py +++ b/tests/components/litterrobot/test_init.py @@ -14,7 +14,6 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from .common import CONFIG, VACUUM_ENTITY_ID, remove_device @@ -73,17 +72,19 @@ async def test_entry_not_setup( async def test_device_remove_devices( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_account: MagicMock + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_account: MagicMock, ) -> None: """Test we can only remove a device that no longer exists.""" assert await async_setup_component(hass, "config", {}) config_entry = await setup_integration(hass, mock_account, VACUUM_DOMAIN) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities[VACUUM_ENTITY_ID] + entity = entity_registry.entities[VACUUM_ENTITY_ID] assert entity.unique_id == "LR3C012345-litter_box" - device_registry = dr.async_get(hass) device_entry = device_registry.async_get(entity.device_id) assert ( await remove_device( diff --git a/tests/components/litterrobot/test_vacuum.py b/tests/components/litterrobot/test_vacuum.py index 3aee7b5075f..fe77119ca5e 100644 --- a/tests/components/litterrobot/test_vacuum.py +++ b/tests/components/litterrobot/test_vacuum.py @@ -32,21 +32,22 @@ COMPONENT_SERVICE_DOMAIN = { } -async def test_vacuum(hass: HomeAssistant, mock_account: MagicMock) -> None: +async def test_vacuum( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_account: MagicMock +) -> None: """Tests the vacuum entity was set up.""" - ent_reg = er.async_get(hass) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( PLATFORM_DOMAIN, DOMAIN, VACUUM_UNIQUE_ID, suggested_object_id=VACUUM_ENTITY_ID.replace(PLATFORM_DOMAIN, ""), ) - ent_reg_entry = ent_reg.async_get(VACUUM_ENTITY_ID) + ent_reg_entry = entity_registry.async_get(VACUUM_ENTITY_ID) assert ent_reg_entry.unique_id == VACUUM_UNIQUE_ID await setup_integration(hass, mock_account, PLATFORM_DOMAIN) - assert len(ent_reg.entities) == 1 + assert len(entity_registry.entities) == 1 assert hass.services.has_service(DOMAIN, SERVICE_SET_SLEEP_MODE) vacuum = hass.states.get(VACUUM_ENTITY_ID) @@ -54,7 +55,7 @@ async def test_vacuum(hass: HomeAssistant, mock_account: MagicMock) -> None: assert vacuum.state == STATE_DOCKED assert vacuum.attributes["is_sleeping"] is False - ent_reg_entry = ent_reg.async_get(VACUUM_ENTITY_ID) + ent_reg_entry = entity_registry.async_get(VACUUM_ENTITY_ID) assert ent_reg_entry.unique_id == VACUUM_UNIQUE_ID @@ -70,15 +71,16 @@ async def test_vacuum_status_when_sleeping( async def test_no_robots( - hass: HomeAssistant, mock_account_with_no_robots: MagicMock + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_account_with_no_robots: MagicMock, ) -> None: """Tests the vacuum entity was set up.""" entry = await setup_integration(hass, mock_account_with_no_robots, PLATFORM_DOMAIN) assert not hass.services.has_service(DOMAIN, SERVICE_SET_SLEEP_MODE) - ent_reg = er.async_get(hass) - assert len(ent_reg.entities) == 0 + assert len(entity_registry.entities) == 0 assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/local_calendar/conftest.py b/tests/components/local_calendar/conftest.py index 7dc294087bd..8455fc2f34f 100644 --- a/tests/components/local_calendar/conftest.py +++ b/tests/components/local_calendar/conftest.py @@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable, Generator from http import HTTPStatus from pathlib import Path from typing import Any -from unittest.mock import patch +from unittest.mock import Mock, patch import urllib from aiohttp import ClientWebSocketResponse @@ -20,24 +20,31 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator CALENDAR_NAME = "Light Schedule" FRIENDLY_NAME = "Light schedule" +STORAGE_KEY = "light_schedule" TEST_ENTITY = "calendar.light_schedule" class FakeStore(LocalCalendarStore): """Mock storage implementation.""" - def __init__(self, hass: HomeAssistant, path: Path, ics_content: str) -> None: + def __init__( + self, hass: HomeAssistant, path: Path, ics_content: str, read_side_effect: Any + ) -> None: """Initialize FakeStore.""" super().__init__(hass, path) - self._content = ics_content + mock_path = self._mock_path = Mock() + mock_path.exists = self._mock_exists + mock_path.read_text = Mock() + mock_path.read_text.return_value = ics_content + mock_path.read_text.side_effect = read_side_effect + mock_path.write_text = self._mock_write_text + super().__init__(hass, mock_path) - def _load(self) -> str: - """Read from calendar storage.""" - return self._content + def _mock_exists(self) -> bool: + return self._mock_path.read_text.return_value is not None - def _store(self, ics_content: str) -> None: - """Persist the calendar storage.""" - self._content = ics_content + def _mock_write_text(self, content: str) -> None: + self._mock_path.read_text.return_value = content @pytest.fixture(name="ics_content", autouse=True) @@ -46,15 +53,23 @@ def mock_ics_content() -> str: return "" +@pytest.fixture(name="store_read_side_effect") +def mock_store_read_side_effect() -> Any | None: + """Fixture to raise errors from the FakeStore.""" + return None + + @pytest.fixture(name="store", autouse=True) -def mock_store(ics_content: str) -> Generator[None, None, None]: +def mock_store( + ics_content: str, store_read_side_effect: Any | None +) -> Generator[None, None, None]: """Test cleanup, remove any media storage persisted during the test.""" stores: dict[Path, FakeStore] = {} def new_store(hass: HomeAssistant, path: Path) -> FakeStore: if path not in stores: - stores[path] = FakeStore(hass, path, ics_content) + stores[path] = FakeStore(hass, path, ics_content, store_read_side_effect) return stores[path] with patch( diff --git a/tests/components/local_calendar/test_config_flow.py b/tests/components/local_calendar/test_config_flow.py index 25049326762..6cebd42cf30 100644 --- a/tests/components/local_calendar/test_config_flow.py +++ b/tests/components/local_calendar/test_config_flow.py @@ -2,10 +2,16 @@ from unittest.mock import patch from homeassistant import config_entries -from homeassistant.components.local_calendar.const import CONF_CALENDAR_NAME, DOMAIN +from homeassistant.components.local_calendar.const import ( + CONF_CALENDAR_NAME, + CONF_STORAGE_KEY, + DOMAIN, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + async def test_form(hass: HomeAssistant) -> None: """Test we get the form.""" @@ -31,5 +37,30 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["title"] == "My Calendar" assert result2["data"] == { CONF_CALENDAR_NAME: "My Calendar", + CONF_STORAGE_KEY: "my_calendar", } assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplicate_name( + hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry +) -> None: + """Test two calendars cannot be added with the same name.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + # Pick a name that has the same slugify value as an existing config entry + CONF_CALENDAR_NAME: "light schedule", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/local_calendar/test_init.py b/tests/components/local_calendar/test_init.py index e5ca209e8a6..8e79cccea36 100644 --- a/tests/components/local_calendar/test_init.py +++ b/tests/components/local_calendar/test_init.py @@ -2,11 +2,36 @@ from unittest.mock import patch +import pytest + +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from .conftest import TEST_ENTITY + from tests.common import MockConfigEntry +async def test_load_unload( + hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry +) -> None: + """Test loading and unloading a config entry.""" + + assert config_entry.state == ConfigEntryState.LOADED + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "off" + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.NOT_LOADED + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "unavailable" + + async def test_remove_config_entry( hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry ) -> None: @@ -16,3 +41,20 @@ async def test_remove_config_entry( assert await hass.config_entries.async_remove(config_entry.entry_id) await hass.async_block_till_done() unlink_mock.assert_called_once() + + +@pytest.mark.parametrize( + ("store_read_side_effect"), + [ + (OSError("read error")), + ], +) +async def test_load_failure( + hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry +) -> None: + """Test failures loading the store.""" + + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + state = hass.states.get(TEST_ENTITY) + assert not state diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py index 5747e05ad05..67d0703ca7c 100644 --- a/tests/components/local_todo/test_todo.py +++ b/tests/components/local_todo/test_todo.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable import textwrap +from typing import Any import pytest @@ -13,39 +14,22 @@ from .conftest import TEST_ENTITY from tests.typing import WebSocketGenerator -@pytest.fixture -def ws_req_id() -> Callable[[], int]: - """Fixture for incremental websocket requests.""" - - id = 0 - - def next() -> int: - nonlocal id - id += 1 - return id - - return next - - @pytest.fixture async def ws_get_items( - hass_ws_client: WebSocketGenerator, ws_req_id: Callable[[], int] + hass_ws_client: WebSocketGenerator, ) -> Callable[[], Awaitable[dict[str, str]]]: """Fixture to fetch items from the todo websocket.""" async def get() -> list[dict[str, str]]: # Fetch items using To-do platform client = await hass_ws_client() - id = ws_req_id() - await client.send_json( + await client.send_json_auto_id( { - "id": id, "type": "todo/item/list", "entity_id": TEST_ENTITY, } ) resp = await client.receive_json() - assert resp.get("id") == id assert resp.get("success") return resp.get("result", {}).get("items", []) @@ -55,35 +39,51 @@ async def ws_get_items( @pytest.fixture async def ws_move_item( hass_ws_client: WebSocketGenerator, - ws_req_id: Callable[[], int], ) -> Callable[[str, str | None], Awaitable[None]]: """Fixture to move an item in the todo list.""" async def move(uid: str, previous_uid: str | None) -> None: # Fetch items using To-do platform client = await hass_ws_client() - id = ws_req_id() data = { - "id": id, "type": "todo/item/move", "entity_id": TEST_ENTITY, "uid": uid, } if previous_uid is not None: data["previous_uid"] = previous_uid - await client.send_json(data) + await client.send_json_auto_id(data) resp = await client.receive_json() - assert resp.get("id") == id assert resp.get("success") return move +@pytest.fixture(autouse=True) +def set_time_zone(hass: HomeAssistant) -> None: + """Set the time zone for the tests that keesp UTC-6 all year round.""" + hass.config.set_time_zone("America/Regina") + + +@pytest.mark.parametrize( + ("item_data", "expected_item_data"), + [ + ({}, {}), + ({"due_date": "2023-11-17"}, {"due": "2023-11-17"}), + ( + {"due_datetime": "2023-11-17T11:30:00+00:00"}, + {"due": "2023-11-17T05:30:00-06:00"}, + ), + ({"description": "Additional detail"}, {"description": "Additional detail"}), + ], +) async def test_add_item( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_integration: None, ws_get_items: Callable[[], Awaitable[dict[str, str]]], + item_data: dict[str, Any], + expected_item_data: dict[str, Any], ) -> None: """Test adding a todo item.""" @@ -94,7 +94,7 @@ async def test_add_item( await hass.services.async_call( TODO_DOMAIN, "add_item", - {"item": "replace batteries"}, + {"item": "replace batteries", **item_data}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -103,6 +103,8 @@ async def test_add_item( assert len(items) == 1 assert items[0]["summary"] == "replace batteries" assert items[0]["status"] == "needs_action" + for k, v in expected_item_data.items(): + assert items[0][k] == v assert "uid" in items[0] state = hass.states.get(TEST_ENTITY) @@ -110,16 +112,30 @@ async def test_add_item( assert state.state == "1" +@pytest.mark.parametrize( + ("item_data", "expected_item_data"), + [ + ({}, {}), + ({"due_date": "2023-11-17"}, {"due": "2023-11-17"}), + ( + {"due_datetime": "2023-11-17T11:30:00+00:00"}, + {"due": "2023-11-17T05:30:00-06:00"}, + ), + ({"description": "Additional detail"}, {"description": "Additional detail"}), + ], +) async def test_remove_item( hass: HomeAssistant, setup_integration: None, ws_get_items: Callable[[], Awaitable[dict[str, str]]], + item_data: dict[str, Any], + expected_item_data: dict[str, Any], ) -> None: """Test removing a todo item.""" await hass.services.async_call( TODO_DOMAIN, "add_item", - {"item": "replace batteries"}, + {"item": "replace batteries", **item_data}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -128,6 +144,8 @@ async def test_remove_item( assert len(items) == 1 assert items[0]["summary"] == "replace batteries" assert items[0]["status"] == "needs_action" + for k, v in expected_item_data.items(): + assert items[0][k] == v assert "uid" in items[0] state = hass.states.get(TEST_ENTITY) @@ -189,10 +207,30 @@ async def test_bulk_remove( assert state.state == "0" +@pytest.mark.parametrize( + ("item_data", "expected_item_data", "expected_state"), + [ + ({"status": "completed"}, {"status": "completed"}, "0"), + ({"due_date": "2023-11-17"}, {"due": "2023-11-17"}, "1"), + ( + {"due_datetime": "2023-11-17T11:30:00+00:00"}, + {"due": "2023-11-17T05:30:00-06:00"}, + "1", + ), + ( + {"description": "Additional detail"}, + {"description": "Additional detail"}, + "1", + ), + ], +) async def test_update_item( hass: HomeAssistant, setup_integration: None, ws_get_items: Callable[[], Awaitable[dict[str, str]]], + item_data: dict[str, Any], + expected_item_data: dict[str, Any], + expected_state: str, ) -> None: """Test updating a todo item.""" @@ -220,21 +258,22 @@ async def test_update_item( await hass.services.async_call( TODO_DOMAIN, "update_item", - {"item": item["uid"], "status": "completed"}, + {"item": item["uid"], **item_data}, target={"entity_id": TEST_ENTITY}, blocking=True, ) - # Verify item is marked as completed + # Verify item is updated items = await ws_get_items() assert len(items) == 1 item = items[0] assert item["summary"] == "soda" - assert item["status"] == "completed" + for k, v in expected_item_data.items(): + assert items[0][k] == v state = hass.states.get(TEST_ENTITY) assert state - assert state.state == "0" + assert state.state == expected_state async def test_rename( @@ -466,3 +505,64 @@ async def test_parse_existing_ics( state = hass.states.get(TEST_ENTITY) assert state assert state.state == expected_state + + +async def test_susbcribe( + hass: HomeAssistant, + setup_integration: None, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test subscribing to item updates.""" + + # Create new item + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "soda"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Subscribe and get the initial list + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "todo/item/subscribe", + "entity_id": TEST_ENTITY, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "soda" + assert items[0]["status"] == "needs_action" + uid = items[0]["uid"] + assert uid + + # Rename item + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": uid, "rename": "milk"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify update is published + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "milk" + assert items[0]["status"] == "needs_action" + assert "uid" in items[0] diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index 24b13d48a1e..16f40fda786 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -42,6 +42,8 @@ class MockLockEntity(LockEntity): ) -> None: """Initialize mock lock entity.""" self._attr_supported_features = supported_features + self.calls_lock = MagicMock() + self.calls_unlock = MagicMock() self.calls_open = MagicMock() if code_format is not None: self._attr_code_format = code_format @@ -49,11 +51,13 @@ class MockLockEntity(LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" + self.calls_lock(kwargs) self._attr_is_locking = False self._attr_is_locked = True async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" + self.calls_unlock(kwargs) self._attr_is_unlocking = False self._attr_is_locked = False @@ -103,10 +107,10 @@ async def test_lock_states(hass: HomeAssistant) -> None: async def test_set_default_code_option( hass: HomeAssistant, + entity_registry: er.EntityRegistry, enable_custom_integrations: None, ) -> None: """Test default code stored in the registry.""" - entity_registry = er.async_get(hass) entry = entity_registry.async_get_or_create("lock", "test", "very_unique") await hass.async_block_till_done() @@ -134,10 +138,10 @@ async def test_set_default_code_option( async def test_default_code_option_update( hass: HomeAssistant, + entity_registry: er.EntityRegistry, enable_custom_integrations: None, ) -> None: """Test default code stored in the registry is updated.""" - entity_registry = er.async_get(hass) entry = entity_registry.async_get_or_create("lock", "test", "very_unique") await hass.async_block_till_done() @@ -232,6 +236,50 @@ async def test_lock_unlock_with_code(hass: HomeAssistant) -> None: assert not lock.is_locked +async def test_lock_with_illegal_code(hass: HomeAssistant) -> None: + """Test lock entity with default code that does not match the code format.""" + lock = MockLockEntity( + code_format=r"^\d{4}$", + supported_features=LockEntityFeature.OPEN, + ) + lock.hass = hass + + with pytest.raises(ValueError): + await _async_open( + lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "123456"}) + ) + with pytest.raises(ValueError): + await _async_lock( + lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "123456"}) + ) + with pytest.raises(ValueError): + await _async_unlock( + lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "123456"}) + ) + + +async def test_lock_with_no_code(hass: HomeAssistant) -> None: + """Test lock entity with default code that does not match the code format.""" + lock = MockLockEntity( + supported_features=LockEntityFeature.OPEN, + ) + lock.hass = hass + + await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + lock.calls_open.assert_called_with({}) + await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + lock.calls_lock.assert_called_with({}) + await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + lock.calls_unlock.assert_called_with({}) + + await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: ""})) + lock.calls_open.assert_called_with({}) + await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: ""})) + lock.calls_lock.assert_called_with({}) + await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: ""})) + lock.calls_unlock.assert_called_with({}) + + async def test_lock_with_default_code(hass: HomeAssistant) -> None: """Test lock entity with default code.""" lock = MockLockEntity( @@ -245,5 +293,52 @@ async def test_lock_with_default_code(hass: HomeAssistant) -> None: assert lock._lock_option_default_code == "1234" await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + lock.calls_open.assert_called_with({ATTR_CODE: "1234"}) await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + lock.calls_lock.assert_called_with({ATTR_CODE: "1234"}) await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + lock.calls_unlock.assert_called_with({ATTR_CODE: "1234"}) + + await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: ""})) + lock.calls_open.assert_called_with({ATTR_CODE: "1234"}) + await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: ""})) + lock.calls_lock.assert_called_with({ATTR_CODE: "1234"}) + await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: ""})) + lock.calls_unlock.assert_called_with({ATTR_CODE: "1234"}) + + +async def test_lock_with_provided_and_default_code(hass: HomeAssistant) -> None: + """Test lock entity with provided code when default code is set.""" + lock = MockLockEntity( + code_format=r"^\d{4}$", + supported_features=LockEntityFeature.OPEN, + lock_option_default_code="1234", + ) + lock.hass = hass + + await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "4321"})) + lock.calls_open.assert_called_with({ATTR_CODE: "4321"}) + await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "4321"})) + lock.calls_lock.assert_called_with({ATTR_CODE: "4321"}) + await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "4321"})) + lock.calls_unlock.assert_called_with({ATTR_CODE: "4321"}) + + +async def test_lock_with_illegal_default_code(hass: HomeAssistant) -> None: + """Test lock entity with default code that does not match the code format.""" + lock = MockLockEntity( + code_format=r"^\d{4}$", + supported_features=LockEntityFeature.OPEN, + lock_option_default_code="123456", + ) + lock.hass = hass + + assert lock.state_attributes == {"code_format": r"^\d{4}$"} + assert lock._lock_option_default_code == "123456" + + with pytest.raises(ValueError): + await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + with pytest.raises(ValueError): + await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + with pytest.raises(ValueError): + await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index eaa2a1e4192..d95b409a67b 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -493,9 +493,13 @@ async def test_logbook_describe_event( hass, "fake_integration.logbook", Mock( - async_describe_events=lambda hass, async_describe_event: async_describe_event( - "test_domain", "some_event", _describe - ) + async_describe_events=( + lambda hass, async_describe_event: async_describe_event( + "test_domain", + "some_event", + _describe, + ) + ), ), ) diff --git a/tests/components/logbook/test_models.py b/tests/components/logbook/test_models.py new file mode 100644 index 00000000000..6f3c6bfefcb --- /dev/null +++ b/tests/components/logbook/test_models.py @@ -0,0 +1,20 @@ +"""The tests for the logbook component models.""" +from unittest.mock import Mock + +from homeassistant.components.logbook.models import LazyEventPartialState + + +def test_lazy_event_partial_state_context(): + """Test we can extract context from a lazy event partial state.""" + state = LazyEventPartialState( + Mock( + context_id_bin=b"1234123412341234", + context_user_id_bin=b"1234123412341234", + context_parent_id_bin=b"4444444444444444", + event_data={}, + ), + {}, + ) + assert state.context_id == "1H68SK8C9J6CT32CHK6GRK4CSM" + assert state.context_user_id == "31323334313233343132333431323334" + assert state.context_parent_id == "1M6GT38D1M6GT38D1M6GT38D1M" diff --git a/tests/components/luftdaten/conftest.py b/tests/components/luftdaten/conftest.py index 248e1344f1b..08cbe7a2c3c 100644 --- a/tests/components/luftdaten/conftest.py +++ b/tests/components/luftdaten/conftest.py @@ -33,24 +33,16 @@ def mock_setup_entry() -> Generator[None, None, None]: yield -@pytest.fixture -def mock_luftdaten_config_flow() -> Generator[None, MagicMock, None]: - """Return a mocked Luftdaten client.""" - with patch( - "homeassistant.components.luftdaten.config_flow.Luftdaten", autospec=True - ) as luftdaten_mock: - luftdaten = luftdaten_mock.return_value - luftdaten.validate_sensor.return_value = True - yield luftdaten - - @pytest.fixture def mock_luftdaten() -> Generator[None, MagicMock, None]: """Return a mocked Luftdaten client.""" with patch( "homeassistant.components.luftdaten.Luftdaten", autospec=True - ) as luftdaten_mock: + ) as luftdaten_mock, patch( + "homeassistant.components.luftdaten.config_flow.Luftdaten", new=luftdaten_mock + ): luftdaten = luftdaten_mock.return_value + luftdaten.validate_sensor.return_value = True luftdaten.sensor_id = 12345 luftdaten.meta = { "altitude": 123.456, diff --git a/tests/components/luftdaten/test_config_flow.py b/tests/components/luftdaten/test_config_flow.py index 5197a101bfd..a0b741f7d2a 100644 --- a/tests/components/luftdaten/test_config_flow.py +++ b/tests/components/luftdaten/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock from luftdaten.exceptions import LuftdatenConnectionError +import pytest from homeassistant.components.luftdaten import DOMAIN from homeassistant.components.luftdaten.const import CONF_SENSOR_ID @@ -36,7 +37,7 @@ async def test_duplicate_error( async def test_communication_error( - hass: HomeAssistant, mock_luftdaten_config_flow: MagicMock + hass: HomeAssistant, mock_luftdaten: MagicMock ) -> None: """Test that no sensor is added while unable to communicate with API.""" result = await hass.config_entries.flow.async_init( @@ -46,7 +47,7 @@ async def test_communication_error( assert result.get("type") == FlowResultType.FORM assert result.get("step_id") == "user" - mock_luftdaten_config_flow.get_data.side_effect = LuftdatenConnectionError + mock_luftdaten.get_data.side_effect = LuftdatenConnectionError result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_SENSOR_ID: 12345}, @@ -56,7 +57,7 @@ async def test_communication_error( assert result2.get("step_id") == "user" assert result2.get("errors") == {CONF_SENSOR_ID: "cannot_connect"} - mock_luftdaten_config_flow.get_data.side_effect = None + mock_luftdaten.get_data.side_effect = None result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], user_input={CONF_SENSOR_ID: 12345}, @@ -70,9 +71,7 @@ async def test_communication_error( } -async def test_invalid_sensor( - hass: HomeAssistant, mock_luftdaten_config_flow: MagicMock -) -> None: +async def test_invalid_sensor(hass: HomeAssistant, mock_luftdaten: MagicMock) -> None: """Test that an invalid sensor throws an error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -81,7 +80,7 @@ async def test_invalid_sensor( assert result.get("type") == FlowResultType.FORM assert result.get("step_id") == "user" - mock_luftdaten_config_flow.validate_sensor.return_value = False + mock_luftdaten.validate_sensor.return_value = False result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_SENSOR_ID: 11111}, @@ -91,7 +90,7 @@ async def test_invalid_sensor( assert result2.get("step_id") == "user" assert result2.get("errors") == {CONF_SENSOR_ID: "invalid_sensor"} - mock_luftdaten_config_flow.validate_sensor.return_value = True + mock_luftdaten.validate_sensor.return_value = True result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], user_input={CONF_SENSOR_ID: 12345}, @@ -105,10 +104,9 @@ async def test_invalid_sensor( } +@pytest.mark.usefixtures("mock_setup_entry", "mock_luftdaten") async def test_step_user( hass: HomeAssistant, - mock_setup_entry: MagicMock, - mock_luftdaten_config_flow: MagicMock, ) -> None: """Test that the user step works.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/luftdaten/test_sensor.py b/tests/components/luftdaten/test_sensor.py index e9e86fd9f1b..7a2cac1721b 100644 --- a/tests/components/luftdaten/test_sensor.py +++ b/tests/components/luftdaten/test_sensor.py @@ -23,11 +23,11 @@ from tests.common import MockConfigEntry async def test_luftdaten_sensors( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, ) -> None: """Test the Luftdaten sensors.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) entry = entity_registry.async_get("sensor.sensor_12345_temperature") assert entry diff --git a/tests/components/lutron_caseta/test_button.py b/tests/components/lutron_caseta/test_button.py index 68742e5bae3..378db23715c 100644 --- a/tests/components/lutron_caseta/test_button.py +++ b/tests/components/lutron_caseta/test_button.py @@ -8,7 +8,9 @@ from homeassistant.helpers import entity_registry as er from . import MockBridge, async_setup_integration -async def test_button_unique_id(hass: HomeAssistant) -> None: +async def test_button_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a button unique id.""" await async_setup_integration(hass, MockBridge) @@ -17,8 +19,6 @@ async def test_button_unique_id(hass: HomeAssistant) -> None: ) caseta_button_entity_id = "button.dining_room_pico_stop" - entity_registry = er.async_get(hass) - # Assert that Caseta buttons will have the bridge serial hash and the zone id as the uniqueID assert entity_registry.async_get(ra3_button_entity_id).unique_id == "000004d2_1372" assert ( diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py index da26a55a4ef..631cb0ff1e7 100644 --- a/tests/components/lutron_caseta/test_config_flow.py +++ b/tests/components/lutron_caseta/test_config_flow.py @@ -60,7 +60,8 @@ async def test_bridge_import_flow(hass: HomeAssistant) -> None: ) as mock_setup_entry, patch( "homeassistant.components.lutron_caseta.async_setup", return_value=True ), patch.object( - Smartbridge, "create_tls" + Smartbridge, + "create_tls", ) as create_tls: create_tls.return_value = MockBridge(can_connect=True) diff --git a/tests/components/lutron_caseta/test_cover.py b/tests/components/lutron_caseta/test_cover.py index ef5fc2a5228..7fe8ed22866 100644 --- a/tests/components/lutron_caseta/test_cover.py +++ b/tests/components/lutron_caseta/test_cover.py @@ -7,13 +7,13 @@ from homeassistant.helpers import entity_registry as er from . import MockBridge, async_setup_integration -async def test_cover_unique_id(hass: HomeAssistant) -> None: +async def test_cover_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a light unique id.""" await async_setup_integration(hass, MockBridge) cover_entity_id = "cover.basement_bedroom_left_shade" - entity_registry = er.async_get(hass) - # Assert that Caseta covers will have the bridge serial hash and the zone id as the uniqueID assert entity_registry.async_get(cover_entity_id).unique_id == "000004d2_802" diff --git a/tests/components/lutron_caseta/test_fan.py b/tests/components/lutron_caseta/test_fan.py index f9c86cc9c58..0147817514d 100644 --- a/tests/components/lutron_caseta/test_fan.py +++ b/tests/components/lutron_caseta/test_fan.py @@ -7,13 +7,13 @@ from homeassistant.helpers import entity_registry as er from . import MockBridge, async_setup_integration -async def test_fan_unique_id(hass: HomeAssistant) -> None: +async def test_fan_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a light unique id.""" await async_setup_integration(hass, MockBridge) fan_entity_id = "fan.master_bedroom_ceiling_fan" - entity_registry = er.async_get(hass) - # Assert that Caseta covers will have the bridge serial hash and the zone id as the uniqueID assert entity_registry.async_get(fan_entity_id).unique_id == "000004d2_804" diff --git a/tests/components/lutron_caseta/test_light.py b/tests/components/lutron_caseta/test_light.py index 6449ce04832..cdba9a956e5 100644 --- a/tests/components/lutron_caseta/test_light.py +++ b/tests/components/lutron_caseta/test_light.py @@ -8,15 +8,15 @@ from homeassistant.helpers import entity_registry as er from . import MockBridge, async_setup_integration -async def test_light_unique_id(hass: HomeAssistant) -> None: +async def test_light_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a light unique id.""" await async_setup_integration(hass, MockBridge) ra3_entity_id = "light.basement_bedroom_main_lights" caseta_entity_id = "light.kitchen_main_lights" - entity_registry = er.async_get(hass) - # Assert that RA3 lights will have the bridge serial hash and the zone id as the uniqueID assert entity_registry.async_get(ra3_entity_id).unique_id == "000004d2_801" diff --git a/tests/components/lutron_caseta/test_logbook.py b/tests/components/lutron_caseta/test_logbook.py index 8390370d16d..c0bac43ba6f 100644 --- a/tests/components/lutron_caseta/test_logbook.py +++ b/tests/components/lutron_caseta/test_logbook.py @@ -82,7 +82,7 @@ async def test_humanify_lutron_caseta_button_event(hass: HomeAssistant) -> None: async def test_humanify_lutron_caseta_button_event_integration_not_loaded( - hass: HomeAssistant, + hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: """Test humanifying lutron_caseta_button_events when the integration fails to load.""" hass.config.components.add("recorder") @@ -109,7 +109,6 @@ async def test_humanify_lutron_caseta_button_event_integration_not_loaded( await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - device_registry = dr.async_get(hass) for device in device_registry.devices.values(): if device.config_entries == {config_entry.entry_id}: dr_device_id = device.id @@ -140,14 +139,15 @@ async def test_humanify_lutron_caseta_button_event_integration_not_loaded( assert event1["message"] == "press stop" -async def test_humanify_lutron_caseta_button_event_ra3(hass: HomeAssistant) -> None: +async def test_humanify_lutron_caseta_button_event_ra3( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test humanifying lutron_caseta_button_events from an RA3 hub.""" hass.config.components.add("recorder") assert await async_setup_component(hass, "logbook", {}) await async_setup_integration(hass, MockBridge) - registry = dr.async_get(hass) - keypad = registry.async_get_device( + keypad = device_registry.async_get_device( identifiers={(DOMAIN, 66286451)}, connections=set() ) assert keypad @@ -176,14 +176,15 @@ async def test_humanify_lutron_caseta_button_event_ra3(hass: HomeAssistant) -> N assert event1["message"] == "press Kitchen Pendants" -async def test_humanify_lutron_caseta_button_unknown_type(hass: HomeAssistant) -> None: +async def test_humanify_lutron_caseta_button_unknown_type( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test humanifying lutron_caseta_button_events with an unknown type.""" hass.config.components.add("recorder") assert await async_setup_component(hass, "logbook", {}) await async_setup_integration(hass, MockBridge) - registry = dr.async_get(hass) - keypad = registry.async_get_device( + keypad = device_registry.async_get_device( identifiers={(DOMAIN, 66286451)}, connections=set() ) assert keypad diff --git a/tests/components/lutron_caseta/test_switch.py b/tests/components/lutron_caseta/test_switch.py index 842aca94423..c38305ec26b 100644 --- a/tests/components/lutron_caseta/test_switch.py +++ b/tests/components/lutron_caseta/test_switch.py @@ -6,13 +6,13 @@ from homeassistant.helpers import entity_registry as er from . import MockBridge, async_setup_integration -async def test_switch_unique_id(hass: HomeAssistant) -> None: +async def test_switch_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a light unique id.""" await async_setup_integration(hass, MockBridge) switch_entity_id = "switch.basement_bathroom_exhaust_fan" - entity_registry = er.async_get(hass) - # Assert that Caseta covers will have the bridge serial hash and the zone id as the uniqueID assert entity_registry.async_get(switch_entity_id).unique_id == "000004d2_803" diff --git a/tests/components/marytts/test_tts.py b/tests/components/marytts/test_tts.py index 4282b86ec2e..474d2f19faf 100644 --- a/tests/components/marytts/test_tts.py +++ b/tests/components/marytts/test_tts.py @@ -1,9 +1,12 @@ """The tests for the MaryTTS speech platform.""" +from http import HTTPStatus +import io from unittest.mock import patch +import wave import pytest -from homeassistant.components import media_source, tts +from homeassistant.components import tts from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, DOMAIN as DOMAIN_MP, @@ -13,15 +16,19 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import assert_setup_component, async_mock_service +from tests.components.tts.common import retrieve_media +from tests.typing import ClientSessionGenerator -async def get_media_source_url(hass, media_content_id): - """Get the media source url.""" - if media_source.DOMAIN not in hass.config.components: - assert await async_setup_component(hass, media_source.DOMAIN, {}) +def get_empty_wav() -> bytes: + """Get bytes for empty WAV file.""" + with io.BytesIO() as wav_io: + with wave.open(wav_io, "wb") as wav_file: + wav_file.setframerate(22050) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) - resolved = await media_source.async_resolve_media(hass, media_content_id, None) - return resolved.url + return wav_io.getvalue() @pytest.fixture(autouse=True) @@ -39,7 +46,9 @@ async def test_setup_component(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_service_say(hass: HomeAssistant) -> None: +async def test_service_say( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: """Test service call say.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -51,7 +60,7 @@ async def test_service_say(hass: HomeAssistant) -> None: with patch( "homeassistant.components.marytts.tts.MaryTTS.speak", - return_value=b"audio", + return_value=get_empty_wav(), ) as mock_speak: await hass.services.async_call( tts.DOMAIN, @@ -63,16 +72,22 @@ async def test_service_say(hass: HomeAssistant) -> None: blocking=True, ) - url = await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media( + hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID] + ) + == HTTPStatus.OK + ) mock_speak.assert_called_once() mock_speak.assert_called_with("HomeAssistant", {}) assert len(calls) == 1 - assert url.endswith(".wav") -async def test_service_say_with_effect(hass: HomeAssistant) -> None: +async def test_service_say_with_effect( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: """Test service call say with effects.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -84,7 +99,7 @@ async def test_service_say_with_effect(hass: HomeAssistant) -> None: with patch( "homeassistant.components.marytts.tts.MaryTTS.speak", - return_value=b"audio", + return_value=get_empty_wav(), ) as mock_speak: await hass.services.async_call( tts.DOMAIN, @@ -96,16 +111,22 @@ async def test_service_say_with_effect(hass: HomeAssistant) -> None: blocking=True, ) - url = await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media( + hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID] + ) + == HTTPStatus.OK + ) mock_speak.assert_called_once() mock_speak.assert_called_with("HomeAssistant", {"Volume": "amount:2.0;"}) assert len(calls) == 1 - assert url.endswith(".wav") -async def test_service_say_http_error(hass: HomeAssistant) -> None: +async def test_service_say_http_error( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: """Test service call say.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -129,7 +150,11 @@ async def test_service_say_http_error(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - with pytest.raises(Exception): - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media( + hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID] + ) + == HTTPStatus.NOT_FOUND + ) mock_speak.assert_called_once() diff --git a/tests/components/matter/common.py b/tests/components/matter/common.py index a0935154054..d5093367db5 100644 --- a/tests/components/matter/common.py +++ b/tests/components/matter/common.py @@ -71,6 +71,10 @@ async def trigger_subscription_callback( data: Any = None, ) -> None: """Trigger a subscription callback.""" - callback = client.subscribe_events.call_args.kwargs["callback"] - callback(event, data) + # trigger callback on all subscribers + for sub in client.subscribe_events.call_args_list: + callback = sub.kwargs["callback"] + event_filter = sub.kwargs.get("event_filter") + if event_filter in (None, event): + callback(event, data) await hass.async_block_till_done() diff --git a/tests/components/matter/fixtures/config_entry_diagnostics.json b/tests/components/matter/fixtures/config_entry_diagnostics.json index 53477792e43..f591709fbda 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics.json @@ -40,11 +40,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -76,8 +76,8 @@ "0/40/17": true, "0/40/18": "869D5F986B588B29", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -122,8 +122,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -155,14 +155,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YFX5V0js", - "IPv4Addresses": ["wKgBIw=="], - "IPv6Addresses": ["/oAAAAAAAABiVfn//ldI7A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "YFX5V0js", + "5": ["wKgBIw=="], + "6": ["/oAAAAAAAABiVfn//ldI7A=="], + "7": 1 } ], "0/51/1": 3, @@ -503,19 +503,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 5, - "label": "", - "fabricIndex": 1 + "1": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", + "2": 65521, + "3": 1, + "4": 5, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -540,20 +540,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, diff --git a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json index 8a67ef0fb63..c85ee4d70e3 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json @@ -42,11 +42,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -78,8 +78,8 @@ "0/40/17": true, "0/40/18": "869D5F986B588B29", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -124,8 +124,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -157,14 +157,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YFX5V0js", - "IPv4Addresses": ["wKgBIw=="], - "IPv6Addresses": ["/oAAAAAAAABiVfn//ldI7A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "YFX5V0js", + "5": ["wKgBIw=="], + "6": ["/oAAAAAAAABiVfn//ldI7A=="], + "7": 1 } ], "0/51/1": 3, @@ -317,19 +317,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 5, - "label": "", - "fabricIndex": 1 + "1": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", + "2": 65521, + "3": 1, + "4": 5, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -354,20 +354,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/color-temperature-light.json b/tests/components/matter/fixtures/nodes/color-temperature-light.json index 7552fa833fb..45d1c18635c 100644 --- a/tests/components/matter/fixtures/nodes/color-temperature-light.json +++ b/tests/components/matter/fixtures/nodes/color-temperature-light.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63], @@ -20,11 +20,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 52 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 52 } ], "0/31/1": [], @@ -50,8 +50,8 @@ "0/40/17": true, "0/40/18": "mock-color-temperature-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 + "0": 3, + "1": 65535 }, "0/40/65532": 0, "0/40/65533": 1, @@ -63,8 +63,8 @@ ], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 2, "0/48/3": 2, @@ -77,8 +77,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "ZXRoMA==", - "connected": true + "0": "ZXRoMA==", + "1": true } ], "0/49/4": true, @@ -92,38 +92,38 @@ "0/49/65531": [0, 1, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "eth1", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "ABeILIy4", - "IPv4Addresses": ["CjwBuw=="], - "IPv6Addresses": [ + "0": "eth1", + "1": true, + "2": null, + "3": null, + "4": "ABeILIy4", + "5": ["CjwBuw=="], + "6": [ "/VqgxiAxQiYCF4j//iyMuA==", "IAEEcLs7AAYCF4j//iyMuA==", "/oAAAAAAAAACF4j//iyMuA==" ], - "type": 0 + "7": 0 }, { - "name": "eth0", - "isOperational": false, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAN/ESDO", - "IPv4Addresses": [], - "IPv6Addresses": [], - "type": 2 + "0": "eth0", + "1": false, + "2": null, + "3": null, + "4": "AAN/ESDO", + "5": [], + "6": [], + "7": 2 }, { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 } ], "0/51/1": 4, @@ -151,19 +151,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", - "fabricIndex": 52 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", + "254": 52 } ], "0/62/1": [ { - "rootPublicKey": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 52 + "1": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 52 } ], "0/62/2": 16, @@ -202,8 +202,8 @@ ], "1/29/0": [ { - "deviceType": 268, - "revision": 1 + "0": 268, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 80, 3, 4], @@ -277,19 +277,19 @@ "1/80/1": 0, "1/80/2": [ { - "label": "Dark", - "mode": 0, - "semanticTags": [] + "0": "Dark", + "1": 0, + "2": [] }, { - "label": "Medium", - "mode": 1, - "semanticTags": [] + "0": "Medium", + "1": 1, + "2": [] }, { - "label": "Light", - "mode": 2, - "semanticTags": [] + "0": "Light", + "1": 2, + "2": [] } ], "1/80/3": 0, diff --git a/tests/components/matter/fixtures/nodes/device_diagnostics.json b/tests/components/matter/fixtures/nodes/device_diagnostics.json index 3abecbdf66f..d95fbe5efa9 100644 --- a/tests/components/matter/fixtures/nodes/device_diagnostics.json +++ b/tests/components/matter/fixtures/nodes/device_diagnostics.json @@ -13,8 +13,8 @@ "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -30,11 +30,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -66,8 +66,8 @@ "0/40/17": true, "0/40/18": "869D5F986B588B29", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -112,8 +112,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -143,14 +143,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YFX5V0js", - "IPv4Addresses": ["wKgBIw=="], - "IPv6Addresses": ["/oAAAAAAAABiVfn//ldI7A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "YFX5V0js", + "5": ["wKgBIw=="], + "6": ["/oAAAAAAAABiVfn//ldI7A=="], + "7": 1 } ], "0/51/1": 3, @@ -302,19 +302,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 5, - "label": "", - "fabricIndex": 1 + "1": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", + "2": 65521, + "3": 1, + "4": 5, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -339,20 +339,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -415,8 +415,8 @@ ], "1/29/0": [ { - "deviceType": 257, - "revision": 1 + "0": 257, + "1": 1 } ], "1/29/1": [3, 4, 6, 8, 29, 768, 1030], diff --git a/tests/components/matter/fixtures/nodes/dimmable-light.json b/tests/components/matter/fixtures/nodes/dimmable-light.json index e14c922857c..7ccc3eef3af 100644 --- a/tests/components/matter/fixtures/nodes/dimmable-light.json +++ b/tests/components/matter/fixtures/nodes/dimmable-light.json @@ -12,8 +12,8 @@ "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -29,11 +29,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "mock-dimmable-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -111,8 +111,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -125,8 +125,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "", - "connected": true + "0": "", + "1": true } ], "0/49/2": 10, @@ -147,14 +147,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "", - "IPv4Addresses": [], - "IPv6Addresses": [], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "", + "5": [], + "6": [], + "7": 1 } ], "0/51/1": 6, @@ -243,19 +243,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": "", - "fabricIndex": 1 + "1": "", + "2": "", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -278,20 +278,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -354,8 +354,8 @@ ], "1/29/0": [ { - "deviceType": 257, - "revision": 1 + "0": 257, + "1": 1 } ], "1/29/1": [3, 4, 6, 8, 29, 768, 1030], diff --git a/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json b/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json index 6cbd75ab09c..dfa7794f28b 100644 --- a/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json +++ b/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json @@ -7,8 +7,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -24,11 +24,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -60,8 +60,8 @@ "0/40/17": true, "0/40/18": "mock-door-lock", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 + "0": 3, + "1": 65535 }, "0/40/65532": 0, "0/40/65533": 1, @@ -121,8 +121,8 @@ "0/47/65531": [0, 1, 2, 6, 65528, 65529, 65530, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 2, @@ -154,28 +154,28 @@ "0/50/65531": [65528, 65529, 65530, 65531, 65532, 65533], "0/51/0": [ { - "name": "eth0", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "/mQDt/2Q", - "IPv4Addresses": ["CjwBaQ=="], - "IPv6Addresses": [ + "0": "eth0", + "1": true, + "2": null, + "3": null, + "4": "/mQDt/2Q", + "5": ["CjwBaQ=="], + "6": [ "/VqgxiAxQib8ZAP//rf9kA==", "IAEEcLs7AAb8ZAP//rf9kA==", "/oAAAAAAAAD8ZAP//rf9kA==" ], - "type": 2 + "7": 2 }, { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 } ], "0/51/1": 1, @@ -195,39 +195,39 @@ ], "0/52/0": [ { - "id": 26957, - "name": "26957", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26957, + "1": "26957", + "2": null, + "3": null, + "4": null }, { - "id": 26956, - "name": "26956", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26956, + "1": "26956", + "2": null, + "3": null, + "4": null }, { - "id": 26955, - "name": "26955", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26955, + "1": "26955", + "2": null, + "3": null, + "4": null }, { - "id": 26953, - "name": "26953", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26953, + "1": "26953", + "2": null, + "3": null, + "4": null }, { - "id": 26952, - "name": "26952", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26952, + "1": "26952", + "2": null, + "3": null, + "4": null } ], "0/52/1": 351120, @@ -358,19 +358,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 16, @@ -395,20 +395,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -443,8 +443,8 @@ ], "1/29/0": [ { - "deviceType": 10, - "revision": 1 + "0": 10, + "1": 1 } ], "1/29/1": [3, 6, 29, 47, 257], diff --git a/tests/components/matter/fixtures/nodes/door-lock.json b/tests/components/matter/fixtures/nodes/door-lock.json index 1477d78aa67..8a3f0fd68dd 100644 --- a/tests/components/matter/fixtures/nodes/door-lock.json +++ b/tests/components/matter/fixtures/nodes/door-lock.json @@ -7,8 +7,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -24,11 +24,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -60,8 +60,8 @@ "0/40/17": true, "0/40/18": "mock-door-lock", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 + "0": 3, + "1": 65535 }, "0/40/65532": 0, "0/40/65533": 1, @@ -121,8 +121,8 @@ "0/47/65531": [0, 1, 2, 6, 65528, 65529, 65530, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 2, @@ -154,28 +154,28 @@ "0/50/65531": [65528, 65529, 65530, 65531, 65532, 65533], "0/51/0": [ { - "name": "eth0", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "/mQDt/2Q", - "IPv4Addresses": ["CjwBaQ=="], - "IPv6Addresses": [ + "0": "eth0", + "1": true, + "2": null, + "3": null, + "4": "/mQDt/2Q", + "5": ["CjwBaQ=="], + "6": [ "/VqgxiAxQib8ZAP//rf9kA==", "IAEEcLs7AAb8ZAP//rf9kA==", "/oAAAAAAAAD8ZAP//rf9kA==" ], - "type": 2 + "7": 2 }, { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 } ], "0/51/1": 1, @@ -195,39 +195,39 @@ ], "0/52/0": [ { - "id": 26957, - "name": "26957", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26957, + "1": "26957", + "2": null, + "3": null, + "4": null }, { - "id": 26956, - "name": "26956", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26956, + "1": "26956", + "2": null, + "3": null, + "4": null }, { - "id": 26955, - "name": "26955", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26955, + "1": "26955", + "2": null, + "3": null, + "4": null }, { - "id": 26953, - "name": "26953", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26953, + "1": "26953", + "2": null, + "3": null, + "4": null }, { - "id": 26952, - "name": "26952", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26952, + "1": "26952", + "2": null, + "3": null, + "4": null } ], "0/52/1": 351120, @@ -358,19 +358,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 16, @@ -395,20 +395,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -443,8 +443,8 @@ ], "1/29/0": [ { - "deviceType": 10, - "revision": 1 + "0": 10, + "1": 1 } ], "1/29/1": [3, 6, 29, 47, 257], diff --git a/tests/components/matter/fixtures/nodes/eve-contact-sensor.json b/tests/components/matter/fixtures/nodes/eve-contact-sensor.json index b0eacfb621c..a009796f940 100644 --- a/tests/components/matter/fixtures/nodes/eve-contact-sensor.json +++ b/tests/components/matter/fixtures/nodes/eve-contact-sensor.json @@ -12,16 +12,16 @@ "0/53/47": 0, "0/53/8": [ { - "extAddress": 12872547289273451492, - "rloc16": 1024, - "routerId": 1, - "nextHop": 0, - "pathCost": 0, - "LQIIn": 3, - "LQIOut": 3, - "age": 142, - "allocated": true, - "linkEstablished": true + "0": 12872547289273451492, + "1": 1024, + "2": 1, + "3": 0, + "4": 0, + "5": 3, + "6": 3, + "7": 142, + "8": true, + "9": true } ], "0/53/29": 1556, @@ -30,20 +30,20 @@ "0/53/40": 519, "0/53/7": [ { - "extAddress": 12872547289273451492, - "age": 654, - "rloc16": 1024, - "linkFrameCounter": 738, - "mleFrameCounter": 418, - "lqi": 3, - "averageRssi": -50, - "lastRssi": -51, - "frameErrorRate": 5, - "messageErrorRate": 0, - "rxOnWhenIdle": true, - "fullThreadDevice": true, - "fullNetworkData": true, - "isChild": false + "0": 12872547289273451492, + "1": 654, + "2": 1024, + "3": 738, + "4": 418, + "5": 3, + "6": -50, + "7": -51, + "8": 5, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false } ], "0/53/33": 66, @@ -124,9 +124,9 @@ "0/53/16": 0, "0/42/0": [ { - "providerNodeID": 1773685588, - "endpoint": 0, - "fabricIndex": 1 + "1": 1773685588, + "2": 0, + "254": 1 } ], "0/42/65528": [], @@ -140,8 +140,8 @@ "0/48/65532": 0, "0/48/65528": [1, 3, 5], "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], @@ -158,25 +158,25 @@ "0/31/1": [], "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/65532": 0, @@ -187,8 +187,8 @@ "0/49/65533": 1, "0/49/1": [ { - "networkID": "Uv50lWMtT7s=", - "connected": true + "0": "Uv50lWMtT7s=", + "1": true } ], "0/49/3": 20, @@ -217,8 +217,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 42, 46, 48, 49, 51, 53, 60, 62, 63], @@ -226,18 +226,18 @@ "0/51/65531": [0, 1, 2, 3, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "ieee802154", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YtmXHFJ/dhk=", - "IPv4Addresses": [], - "IPv6Addresses": [ + "0": "ieee802154", + "1": true, + "2": null, + "3": null, + "4": "YtmXHFJ/dhk=", + "5": [], + "6": [ "/RG+U41GAABynlpPU50e5g==", "/oAAAAAAAABg2ZccUn92GQ==", "/VL+dJVjAAB1cwmi02rvTA==" ], - "type": 4 + "7": 4 } ], "0/51/65529": [0], @@ -261,8 +261,8 @@ "0/40/6": "**REDACTED**", "0/40/3": "Eve Door", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/2": 4874, "0/40/65532": 0, @@ -302,8 +302,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 21, - "revision": 1 + "0": 21, + "1": 1 } ], "1/29/65528": [], diff --git a/tests/components/matter/fixtures/nodes/eve-energy-plug.json b/tests/components/matter/fixtures/nodes/eve-energy-plug.json new file mode 100644 index 00000000000..03ff4ce7dba --- /dev/null +++ b/tests/components/matter/fixtures/nodes/eve-energy-plug.json @@ -0,0 +1,649 @@ +{ + "node_id": 83, + "date_commissioned": "2023-11-30T14:39:37.020026", + "last_interview": "2023-11-30T14:39:37.020029", + "interview_version": 5, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 51, 53, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "254": 1 + }, + { + "254": 2 + }, + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 5 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 3, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Eve Systems", + "0/40/2": 4874, + "0/40/3": "Eve Energy Plug", + "0/40/4": 80, + "0/40/5": "", + "0/40/6": "XX", + "0/40/7": 1, + "0/40/8": "1.1", + "0/40/9": 6650, + "0/40/10": "3.2.1", + "0/40/15": "RV44L1A00081", + "0/40/18": "26E8F90561D17C42", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 18, 19, 65528, 65529, 65531, 65532, + 65533 + ], + "0/42/0": [ + { + "1": 2312386028615903905, + "2": 0, + "254": 1 + } + ], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "cfUKbvsdfsBjT+0=", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "cfUKbvBjdsffwT+0=", + "0/49/7": null, + "0/49/65532": 2, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "ieee802154", + "1": true, + "2": null, + "3": null, + "4": "ymtKI/b4u+4=", + "5": [], + "6": [ + "/oAAAAA13414AAADIa0oj9vi77g==", + "/XH1Cm71434wAAB8TZpoASmxuw==", + "/RtUBAb134134mAAAPypryIKqshA==" + ], + "7": 4 + } + ], + "0/51/1": 95, + "0/51/2": 268574, + "0/51/3": 4406, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [0, 1, 2, 3, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], + "0/53/0": 25, + "0/53/1": 5, + "0/53/2": "MyHome23", + "0/53/3": 14707, + "0/53/4": 8211480967175688173, + "0/53/5": "QP1x9Qfwefu8AAA", + "0/53/6": 0, + "0/53/7": [ + { + "0": 13418684826835773064, + "1": 9, + "2": 3072, + "3": 56455, + "4": 84272, + "5": 1, + "6": -89, + "7": -88, + "8": 16, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 3054316089463545304, + "1": 2, + "2": 12288, + "3": 17170, + "4": 58113, + "5": 3, + "6": -45, + "7": -46, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 3650476115380598997, + "1": 13, + "2": 15360, + "3": 172475, + "4": 65759, + "5": 3, + "6": -17, + "7": -18, + "8": 12, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 11968039652259981925, + "1": 21, + "2": 21504, + "3": 127929, + "4": 55363, + "5": 3, + "6": -74, + "7": -72, + "8": 3, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 17156405262946673420, + "1": 22, + "2": 22528, + "3": 22063, + "4": 137698, + "5": 1, + "6": -92, + "7": -92, + "8": 34, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 17782243871947087975, + "1": 18, + "2": 23552, + "3": 157044, + "4": 122272, + "5": 2, + "6": -81, + "7": -82, + "8": 3, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 8276316979900166010, + "1": 17, + "2": 31744, + "3": 486113, + "4": 298427, + "5": 2, + "6": -83, + "7": -82, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 9121696247933828996, + "1": 48, + "2": 53248, + "3": 651530, + "4": 161559, + "5": 3, + "6": -70, + "7": -71, + "8": 15, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + } + ], + "0/53/8": [ + { + "0": 13418684826835773064, + "1": 3072, + "2": 3, + "3": 15, + "4": 1, + "5": 1, + "6": 1, + "7": 9, + "8": true, + "9": true + }, + { + "0": 0, + "1": 7168, + "2": 7, + "3": 21, + "4": 1, + "5": 0, + "6": 0, + "7": 76, + "8": true, + "9": false + }, + { + "0": 0, + "1": 10240, + "2": 10, + "3": 21, + "4": 1, + "5": 0, + "6": 0, + "7": 243, + "8": true, + "9": false + }, + { + "0": 3054316089463545304, + "1": 12288, + "2": 12, + "3": 15, + "4": 1, + "5": 3, + "6": 3, + "7": 2, + "8": true, + "9": true + }, + { + "0": 3650476115380598997, + "1": 15360, + "2": 15, + "3": 12, + "4": 1, + "5": 3, + "6": 3, + "7": 14, + "8": true, + "9": true + }, + { + "0": 11968039652259981925, + "1": 21504, + "2": 21, + "3": 15, + "4": 1, + "5": 3, + "6": 2, + "7": 22, + "8": true, + "9": true + }, + { + "0": 17156405262946673420, + "1": 22528, + "2": 22, + "3": 52, + "4": 1, + "5": 1, + "6": 0, + "7": 23, + "8": true, + "9": true + }, + { + "0": 17782243871947087975, + "1": 23552, + "2": 23, + "3": 15, + "4": 1, + "5": 2, + "6": 2, + "7": 19, + "8": true, + "9": true + }, + { + "0": 0, + "1": 29696, + "2": 29, + "3": 21, + "4": 1, + "5": 0, + "6": 0, + "7": 31, + "8": true, + "9": false + }, + { + "0": 8276316979900166010, + "1": 31744, + "2": 31, + "3": 52, + "4": 1, + "5": 2, + "6": 2, + "7": 18, + "8": true, + "9": true + }, + { + "0": 0, + "1": 39936, + "2": 39, + "3": 52, + "4": 1, + "5": 0, + "6": 0, + "7": 31, + "8": true, + "9": false + }, + { + "0": 9121696247933828996, + "1": 53248, + "2": 52, + "3": 15, + "4": 1, + "5": 3, + "6": 3, + "7": 48, + "8": true, + "9": true + }, + { + "0": 14585833336497290222, + "1": 54272, + "2": 53, + "3": 63, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "8": true, + "9": false + } + ], + "0/53/9": 1828774034, + "0/53/10": 68, + "0/53/11": 237, + "0/53/12": 170, + "0/53/13": 23, + "0/53/14": 2, + "0/53/15": 1, + "0/53/16": 2, + "0/53/17": 0, + "0/53/18": 0, + "0/53/19": 2, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 293884, + "0/53/23": 278934, + "0/53/24": 14950, + "0/53/25": 278894, + "0/53/26": 278468, + "0/53/27": 14990, + "0/53/28": 293844, + "0/53/29": 0, + "0/53/30": 40, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 65244, + "0/53/34": 426, + "0/53/35": 0, + "0/53/36": 87, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 6687540, + "0/53/40": 142626, + "0/53/41": 106835, + "0/53/42": 246171, + "0/53/43": 0, + "0/53/44": 541, + "0/53/45": 40, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 6360718, + "0/53/49": 2141, + "0/53/50": 35259, + "0/53/51": 4374, + "0/53/52": 0, + "0/53/53": 568, + "0/53/54": 18599, + "0/53/55": 19143, + "0/53/59": { + "0": 672, + "1": 8335 + }, + "0/53/60": "AB//wA==", + "0/53/61": { + "0": true, + "1": false, + "2": true, + "3": true, + "4": true, + "5": true, + "6": false, + "7": true, + "8": true, + "9": true, + "10": true, + "11": true + }, + "0/53/62": [0, 0, 0, 0], + "0/53/65532": 15, + "0/53/65533": 1, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 59, + 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "254": 1 + }, + { + "254": 2 + }, + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRUxgkBwEkCAEwCUEEg58CF25hrI1R598dXwRapPCYUjahad5XkJMrA0tZb8HXO67XlyD4L+1ljtb6IAHhxjOGew2jNVSQDH1aqRGsODcKNQEoARgkAgE2AwQCBAEYMAQUkpBmmh0G57MnnxYDgxZuAZBezjYwBRTphWiJ/NqGe3Cx3Nj8H02NgGioSRgwC0CCOOCnKlhpegJmaH8vSIO38MQcJq+qV85UPPqaYc8dakaAnASvYeurP41Jw4KrCqyLMNRhUwqeyKoql6iQFKNAGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEYztrLK2UY1ORHUEFLO7PDfVjw/MnMDNX5kjdHHDU7npeITnSyg/kxxUM+pD7ccxfDuHQKHbBq9+qbJi8oGik8DcKNQEpARgkAmAwBBTphWiJ/NqGe3Cx3Nj8H02NgGioSTAFFMnf5ZkBCRaBluhSmLJkvcVXxHxTGDALQOOcZAL8XEktvE5sjrUmFNhkP2g3Ef+4BHtogItdZYyA9E/WbzW25E0UxZInwjjIzH3YimDUZVoEWGML8NV2kCEY", + "254": 5 + } + ], + "0/62/1": [ + { + "1": "BIbR4Iu8CNIdxKRkSjTb1LKY3nzCbFVwDrjkRe4WDorCiMZHJmypZW24wBgAHxNo8D00QWw29llu8FH1eOtmHIo=", + "2": 4937, + "3": 1, + "4": 3878431683, + "5": "Thuis", + "254": 1 + }, + { + "1": "BLlk4ui4wSQ+xz89jB5nBRQUVYdY9H2dBUawGXVUxa2bsKh2k8CHijv1tkz1dThPXA9UK8jOAZ+7Mi+y7BPuAcg=", + "2": 4996, + "3": 2, + "4": 3763070728, + "5": "", + "254": 2 + }, + { + "1": "BAg5aeR7RuFKZhukCxMGglCd00dKlhxGq8BbjeyZClKz5kN2Ytzav0xWsiWEEb3s9uvMIYFoQYULnSJvOMTcD14=", + "2": 65521, + "3": 1, + "4": 83, + "5": "", + "254": 5 + } + ], + "0/62/2": 5, + "0/62/3": 3, + "0/62/4": [ + "FTABAQAkAgE3AycUxofpv3kE1HwkFQEYJgS2Ty8rJgU2gxAtNwYnFMaH6b95BNR8JBUBGCQHASQIATAJQQSG0eCLvAjSHcSkZEo029SymN58wmxVcA645EXuFg6KwojGRyZsqWVtuMAYAB8TaPA9NEFsNvZZbvBR9XjrZhyKNwo1ASkBGCQCYDAEFNnFRJ+9qQIJtsM+LRdMdmCY3bQ4MAUU2cVEn72pAgm2wz4tF0x2YJjdtDgYMAtAFDv6Ouh7ugAGLiCjBQaEXCIAe0AkaaN8dBPskCZXOODjuZ1DCr4/f5IYg0rN2zFDUDTvG3GCxoI1+A7BvSjiNRg=", + "FTABAQAkAgE3AycUjuqR8vTQCmEkFQIYJgTFTy8rJgVFgxAtNwYnFI7qkfL00AphJBUCGCQHASQIATAJQQS5ZOLouMEkPsc/PYweZwUUFFWHWPR9nQVGsBl1VMWtm7CodpPAh4o79bZM9XU4T1wPVCvIzgGfuzIvsuwT7gHINwo1ASkBGCQCYDAEFKEEplpzAvCzsc5ga6CFmqmsv5onMAUUoQSmWnMC8LOxzmBroIWaqay/micYMAtAYkkA8OZFIGpxBEYYT+3A7Okba4WOq4NtwctIIZvCM48VU8pxQNjVvHMcJWPOP1Wh2Bw1VH7/Sg9lt9DL4DAwjBg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEECDlp5HtG4UpmG6QLEwaCUJ3TR0qWHEarwFuN7JkKUrPmQ3Zi3Nq/TFayJYQRvez268whgWhBhQudIm84xNwPXjcKNQEpARgkAmAwBBTJ3+WZAQkWgZboUpiyZL3FV8R8UzAFFMnf5ZkBCRaBluhSmLJkvcVXxHxTGDALQO9QSAdvJkM6b/wIc07MCw1ma46lTyGYG8nvpn0ICI73nuD3QeaWwGIQTkVGEpzF+TuDK7gtTz7YUrR+PSnvMk8Y" + ], + "0/62/5": 5, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 3, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/6/0": false, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": null, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/29/0": [ + { + "0": 266, + "1": 1 + } + ], + "1/29/1": [3, 4, 6, 29, 319486977], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/319486977/319422464": "AAFQCwIAAAMC+xkEDFJWNDRMMUEwMDA4MZwBAP8EAQIA1PkBAWABZNAEAAAAAEUFBQAAAABGCQUAAAAOAABCBkkGBQwIEIABRBEFFAAFAzwAAAAAAAAAAAAAAEcRBSoh/CGWImgjeAAAADwAAABIBgUAAAAAAEoGBQAAAAAA", + "1/319486977/319422466": "BEZiAQAAAAAAAAAABgsCDAINAgcCDgEBAn4PABAAWgAAs8c+AQEA", + "1/319486977/319422467": "EgtaAAB74T4BDwAANwkAAAAA", + "1/319486977/319422471": 0, + "1/319486977/319422472": 238.8000030517578, + "1/319486977/319422473": 0.0, + "1/319486977/319422474": 0.0, + "1/319486977/319422475": 0.2200000286102295, + "1/319486977/319422476": 0, + "1/319486977/319422478": 0, + "1/319486977/319422481": false, + "1/319486977/319422482": 54272, + "1/319486977/65533": 1, + "1/319486977/65528": [], + "1/319486977/65529": [], + "1/319486977/65531": [ + 65528, 65529, 65531, 319422464, 319422465, 319422466, 319422467, + 319422468, 319422469, 319422471, 319422472, 319422473, 319422474, + 319422475, 319422476, 319422478, 319422481, 319422482, 65533 + ] + }, + "attribute_subscriptions": [], + "last_subscription_attempt": 0 +} diff --git a/tests/components/matter/fixtures/nodes/extended-color-light.json b/tests/components/matter/fixtures/nodes/extended-color-light.json index f4d83239b6d..d18b76768ca 100644 --- a/tests/components/matter/fixtures/nodes/extended-color-light.json +++ b/tests/components/matter/fixtures/nodes/extended-color-light.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63], @@ -20,11 +20,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 52 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 52 } ], "0/31/1": [], @@ -50,8 +50,8 @@ "0/40/17": true, "0/40/18": "mock-extended-color-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 + "0": 3, + "1": 65535 }, "0/40/65532": 0, "0/40/65533": 1, @@ -63,8 +63,8 @@ ], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 2, "0/48/3": 2, @@ -77,8 +77,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "ZXRoMA==", - "connected": true + "0": "ZXRoMA==", + "1": true } ], "0/49/4": true, @@ -92,38 +92,38 @@ "0/49/65531": [0, 1, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "eth1", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "ABeILIy4", - "IPv4Addresses": ["CjwBuw=="], - "IPv6Addresses": [ + "0": "eth1", + "1": true, + "2": null, + "3": null, + "4": "ABeILIy4", + "5": ["CjwBuw=="], + "6": [ "/VqgxiAxQiYCF4j//iyMuA==", "IAEEcLs7AAYCF4j//iyMuA==", "/oAAAAAAAAACF4j//iyMuA==" ], - "type": 0 + "7": 0 }, { - "name": "eth0", - "isOperational": false, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAN/ESDO", - "IPv4Addresses": [], - "IPv6Addresses": [], - "type": 2 + "0": "eth0", + "1": false, + "2": null, + "3": null, + "4": "AAN/ESDO", + "5": [], + "6": [], + "7": 2 }, { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 } ], "0/51/1": 4, @@ -151,19 +151,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", - "fabricIndex": 52 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", + "254": 52 } ], "0/62/1": [ { - "rootPublicKey": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 52 + "1": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 52 } ], "0/62/2": 16, @@ -202,8 +202,8 @@ ], "1/29/0": [ { - "deviceType": 269, - "revision": 1 + "0": 269, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 80, 3, 4], @@ -277,19 +277,19 @@ "1/80/1": 0, "1/80/2": [ { - "label": "Dark", - "mode": 0, - "semanticTags": [] + "0": "Dark", + "1": 0, + "2": [] }, { - "label": "Medium", - "mode": 1, - "semanticTags": [] + "0": "Medium", + "1": 1, + "2": [] }, { - "label": "Light", - "mode": 2, - "semanticTags": [] + "0": "Light", + "1": 2, + "2": [] } ], "1/80/3": 0, diff --git a/tests/components/matter/fixtures/nodes/flow-sensor.json b/tests/components/matter/fixtures/nodes/flow-sensor.json index e1fc2a36585..a8dad202fa1 100644 --- a/tests/components/matter/fixtures/nodes/flow-sensor.json +++ b/tests/components/matter/fixtures/nodes/flow-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-flow-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 774, - "revision": 1 + "0": 774, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/generic-switch-multi.json b/tests/components/matter/fixtures/nodes/generic-switch-multi.json index 15c93825307..f564e91a1ce 100644 --- a/tests/components/matter/fixtures/nodes/generic-switch-multi.json +++ b/tests/components/matter/fixtures/nodes/generic-switch-multi.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-generic-switch", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 15, - "revision": 1 + "0": 15, + "1": 1 } ], "1/29/1": [3, 29, 59], @@ -77,17 +77,16 @@ "1/59/65528": [], "1/64/0": [ { - "label": "Label", - "value": "1" + "0": "Label", + "1": "1" } ], - "2/3/65529": [0, 64], "2/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "2/29/0": [ { - "deviceType": 15, - "revision": 1 + "0": 15, + "1": 1 } ], "2/29/1": [3, 29, 59], @@ -107,8 +106,8 @@ "2/59/65528": [], "2/64/0": [ { - "label": "Label", - "value": "Fancy Button" + "0": "Label", + "1": "Fancy Button" } ] }, diff --git a/tests/components/matter/fixtures/nodes/generic-switch.json b/tests/components/matter/fixtures/nodes/generic-switch.json index 30763c88e5b..80773915748 100644 --- a/tests/components/matter/fixtures/nodes/generic-switch.json +++ b/tests/components/matter/fixtures/nodes/generic-switch.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-generic-switch", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 15, - "revision": 1 + "0": 15, + "1": 1 } ], "1/29/1": [3, 29, 59], diff --git a/tests/components/matter/fixtures/nodes/humidity-sensor.json b/tests/components/matter/fixtures/nodes/humidity-sensor.json index a1940fc1857..8220c9cf8f8 100644 --- a/tests/components/matter/fixtures/nodes/humidity-sensor.json +++ b/tests/components/matter/fixtures/nodes/humidity-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-humidity-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 775, - "revision": 1 + "0": 775, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/light-sensor.json b/tests/components/matter/fixtures/nodes/light-sensor.json index 93583c34292..c4d84bc7923 100644 --- a/tests/components/matter/fixtures/nodes/light-sensor.json +++ b/tests/components/matter/fixtures/nodes/light-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-light-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 262, - "revision": 1 + "0": 262, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/occupancy-sensor.json b/tests/components/matter/fixtures/nodes/occupancy-sensor.json index d8f2580c2b0..f63dd43362b 100644 --- a/tests/components/matter/fixtures/nodes/occupancy-sensor.json +++ b/tests/components/matter/fixtures/nodes/occupancy-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-temperature-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -61,8 +61,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 263, - "revision": 1 + "0": 263, + "1": 1 } ], "1/29/1": [ diff --git a/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json b/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json index 43ba486bc29..8d523f5443a 100644 --- a/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json +++ b/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-onoff-plugin-unit", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -118,8 +118,8 @@ ], "1/29/0": [ { - "deviceType": 266, - "revision": 1 + "0": 266, + "1": 1 } ], "1/29/1": [ 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 f29361da128..3f6e83ca460 100644 --- a/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json +++ b/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json @@ -29,11 +29,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "mock-onoff-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -111,8 +111,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -125,8 +125,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "", - "connected": true + "0": "", + "1": true } ], "0/49/2": 10, @@ -147,14 +147,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "", - "IPv4Addresses": [""], - "IPv6Addresses": [], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "", + "5": [""], + "6": [], + "7": 1 } ], "0/51/1": 6, @@ -243,19 +243,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": "", - "fabricIndex": 1 + "1": "", + "2": "", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -278,20 +278,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, 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 8a1134409a9..18cb68c8926 100644 --- a/tests/components/matter/fixtures/nodes/onoff-light-no-name.json +++ b/tests/components/matter/fixtures/nodes/onoff-light-no-name.json @@ -29,11 +29,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "mock-onoff-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -111,8 +111,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -125,8 +125,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "", - "connected": true + "0": "", + "1": true } ], "0/49/2": 10, @@ -147,14 +147,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "", - "IPv4Addresses": [""], - "IPv6Addresses": [], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "", + "5": [""], + "6": [], + "7": 1 } ], "0/51/1": 6, @@ -243,19 +243,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": "", - "fabricIndex": 1 + "1": "", + "2": "", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -278,20 +278,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/onoff-light.json b/tests/components/matter/fixtures/nodes/onoff-light.json index 65ef0be5c8e..eed404ff85d 100644 --- a/tests/components/matter/fixtures/nodes/onoff-light.json +++ b/tests/components/matter/fixtures/nodes/onoff-light.json @@ -12,8 +12,8 @@ "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -29,11 +29,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "mock-onoff-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -111,8 +111,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -125,8 +125,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "", - "connected": true + "0": "", + "1": true } ], "0/49/2": 10, @@ -147,14 +147,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "", - "IPv4Addresses": [""], - "IPv6Addresses": [], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "", + "5": [""], + "6": [], + "7": 1 } ], "0/51/1": 6, @@ -243,19 +243,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": "", - "fabricIndex": 1 + "1": "", + "2": "", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -278,20 +278,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -354,8 +354,8 @@ ], "1/29/0": [ { - "deviceType": 257, - "revision": 1 + "0": 257, + "1": 1 } ], "1/29/1": [3, 4, 6, 8, 29, 768, 1030], diff --git a/tests/components/matter/fixtures/nodes/pressure-sensor.json b/tests/components/matter/fixtures/nodes/pressure-sensor.json index a47cda28056..d38ac560ac5 100644 --- a/tests/components/matter/fixtures/nodes/pressure-sensor.json +++ b/tests/components/matter/fixtures/nodes/pressure-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-pressure-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 773, - "revision": 1 + "0": 773, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/switch-unit.json b/tests/components/matter/fixtures/nodes/switch-unit.json index ceed22d2524..e16f1e406ec 100644 --- a/tests/components/matter/fixtures/nodes/switch-unit.json +++ b/tests/components/matter/fixtures/nodes/switch-unit.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 99999, - "revision": 1 + "0": 99999, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-switch-unit", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -96,8 +96,8 @@ "1/7/65531": [0, 16, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 9999999, - "revision": 1 + "0": 9999999, + "1": 1 } ], "1/29/1": [ diff --git a/tests/components/matter/fixtures/nodes/temperature-sensor.json b/tests/components/matter/fixtures/nodes/temperature-sensor.json index c7d372ac2d7..0abb366f81b 100644 --- a/tests/components/matter/fixtures/nodes/temperature-sensor.json +++ b/tests/components/matter/fixtures/nodes/temperature-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-temperature-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -61,8 +61,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 770, - "revision": 1 + "0": 770, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/thermostat.json b/tests/components/matter/fixtures/nodes/thermostat.json index 85ac42e5429..a7abff41331 100644 --- a/tests/components/matter/fixtures/nodes/thermostat.json +++ b/tests/components/matter/fixtures/nodes/thermostat.json @@ -8,8 +8,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 42, 48, 49, 50, 51, 54, 60, 62, 63, 64], @@ -22,18 +22,18 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 2 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 2 } ], "0/31/1": [], @@ -64,8 +64,8 @@ "0/40/17": true, "0/40/18": "3D06D025F9E026A0", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -86,8 +86,8 @@ "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -100,8 +100,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "TE9OR0FOLUlPVA==", - "connected": true + "0": "TE9OR0FOLUlPVA==", + "1": true } ], "0/49/2": 10, @@ -122,18 +122,18 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "3FR1X7qs", - "IPv4Addresses": ["wKgI7g=="], - "IPv6Addresses": [ + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "3FR1X7qs", + "5": ["wKgI7g=="], + "6": [ "/oAAAAAAAADeVHX//l+6rA==", "JA4DsgZ9jUDeVHX//l+6rA==", "/UgvJAe/AADeVHX//l+6rA==" ], - "type": 1 + "7": 1 } ], "0/51/1": 4, @@ -182,32 +182,32 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBBgkBwEkCAEwCUEETaqdhs6MRkbh8fdh4EEImZaziiE6anaVp6Mu3P/zIJUB0fHUMxydKRTAC8bIn7vUhBCM47OYlYTkX0zFhoKYrzcKNQEoARgkAgE2AwQCBAEYMAQUrouBLuksQTkLrFhNVAbTHkNvMSEwBRTPlgMACvPdpqPOzuvR0OfPgfUcxBgwC0AcUInETXp/2gIFGDQF2+u+9WtYtvIfo6C3MhoOIV1SrRBZWYxY3CVjPGK7edTibQrVA4GccZKnHhNSBjxktrPiGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE+rI5XQyifTZbZRK1Z2DOuXdQkmdUkWklTv+G1x4ZfbSupbUDo4l7i/iFdyu//uJThAw1GPEkWe6i98IFKCOQpzcKNQEpARgkAmAwBBTPlgMACvPdpqPOzuvR0OfPgfUcxDAFFJQo6UEBWTLtZVYFZwRBgn+qstpTGDALQK3jYiaxwnYJMwTBQlcVNrGxPtuVTZrp5foZtQCp/JEX2ZWqVxKypilx0ES/CfMHZ0Lllv9QsLs8xV/HNLidllkY", - "fabricIndex": 2 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBBgkBwEkCAEwCUEETaqdhs6MRkbh8fdh4EEImZaziiE6anaVp6Mu3P/zIJUB0fHUMxydKRTAC8bIn7vUhBCM47OYlYTkX0zFhoKYrzcKNQEoARgkAgE2AwQCBAEYMAQUrouBLuksQTkLrFhNVAbTHkNvMSEwBRTPlgMACvPdpqPOzuvR0OfPgfUcxBgwC0AcUInETXp/2gIFGDQF2+u+9WtYtvIfo6C3MhoOIV1SrRBZWYxY3CVjPGK7edTibQrVA4GccZKnHhNSBjxktrPiGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE+rI5XQyifTZbZRK1Z2DOuXdQkmdUkWklTv+G1x4ZfbSupbUDo4l7i/iFdyu//uJThAw1GPEkWe6i98IFKCOQpzcKNQEpARgkAmAwBBTPlgMACvPdpqPOzuvR0OfPgfUcxDAFFJQo6UEBWTLtZVYFZwRBgn+qstpTGDALQK3jYiaxwnYJMwTBQlcVNrGxPtuVTZrp5foZtQCp/JEX2ZWqVxKypilx0ES/CfMHZ0Lllv9QsLs8xV/HNLidllkY", + "254": 2 } ], "0/62/1": [ { - "rootPublicKey": "BAP9BJt5aQ9N98ClPTdNxpMZ1/Vh8r9usw6C8Ygi79AImsJq4UjAaYad0UI9Lh0OmRA9sWE2aSPbHjf409i/970=", - "vendorID": 4996, - "fabricID": 1, - "nodeID": 1425709672, - "label": "", - "fabricIndex": 1 + "1": "BAP9BJt5aQ9N98ClPTdNxpMZ1/Vh8r9usw6C8Ygi79AImsJq4UjAaYad0UI9Lh0OmRA9sWE2aSPbHjf409i/970=", + "2": 4996, + "3": 1, + "4": 1425709672, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJXfyipMp+Jx4pkoTnvYoAYODis4xJktKdQXu8MSpBLIwII58BD0KkIG9NmuHcp0xUQKzqlfyB/bkAanevO73ZI=", - "vendorID": 65521, - "fabricID": 1, - "nodeID": 4, - "label": "", - "fabricIndex": 2 + "1": "BJXfyipMp+Jx4pkoTnvYoAYODis4xJktKdQXu8MSpBLIwII58BD0KkIG9NmuHcp0xUQKzqlfyB/bkAanevO73ZI=", + "2": 65521, + "3": 1, + "4": 4, + "5": "", + "254": 2 } ], "0/62/2": 5, @@ -233,20 +233,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -275,8 +275,8 @@ "1/6/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 769, - "revision": 1 + "0": 769, + "1": 1 } ], "1/29/1": [3, 4, 6, 29, 30, 64, 513, 514, 516], @@ -295,20 +295,20 @@ "1/30/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "1/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/window-covering_full.json b/tests/components/matter/fixtures/nodes/window-covering_full.json index feb75409526..fc6efe2077c 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_full.json +++ b/tests/components/matter/fixtures/nodes/window-covering_full.json @@ -8,8 +8,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], @@ -22,25 +22,25 @@ "0/31/65533": 1, "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/2": 4, @@ -71,8 +71,8 @@ "0/40/17": true, "0/40/18": "mock-full-window-covering", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65533": 1, "0/40/65528": [], @@ -84,8 +84,8 @@ "0/48/2": 0, "0/48/3": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65533": 1, @@ -96,8 +96,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "MTI2MDk5", - "connected": true + "0": "MTI2MDk5", + "1": true } ], "0/49/2": 10, @@ -113,14 +113,14 @@ "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "JG8olrDo", - "IPv4Addresses": ["wKgBFw=="], - "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "JG8olrDo", + "5": ["wKgBFw=="], + "6": ["/oAAAAAAAAAmbyj//paw6A=="], + "7": 1 } ], "0/51/1": 1, @@ -141,47 +141,47 @@ "0/62/65532": 0, "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "", - "icac": null, - "fabricIndex": 2 + "1": "", + "2": null, + "254": 2 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", - "fabricIndex": 3 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "254": 3 } ], "0/62/2": 5, "0/62/3": 3, "0/62/1": [ { - "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", - "vendorId": 24582, - "fabricId": 7331465149450221740, - "nodeId": 3429688654, - "label": "", - "fabricIndex": 1 + "1": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "2": 24582, + "3": 7331465149450221740, + "4": 3429688654, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", - "vendorId": 4362, - "fabricId": 8516517930550670493, - "nodeId": 1443093566726981311, - "label": "", - "fabricIndex": 2 + "1": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "2": 4362, + "3": 8516517930550670493, + "4": 1443093566726981311, + "5": "", + "254": 2 }, { - "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", - "vendorId": 4939, - "fabricId": 2, - "nodeId": 50, - "label": "", - "fabricIndex": 3 + "1": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "2": 4939, + "3": 2, + "4": 50, + "5": "", + "254": 3 } ], "0/62/4": [ @@ -216,8 +216,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 514, - "revision": 2 + "0": 514, + "1": 2 } ], "1/29/1": [29, 3, 258], diff --git a/tests/components/matter/fixtures/nodes/window-covering_lift.json b/tests/components/matter/fixtures/nodes/window-covering_lift.json index afc2a2f734f..9c58869e988 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_lift.json +++ b/tests/components/matter/fixtures/nodes/window-covering_lift.json @@ -8,8 +8,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], @@ -22,25 +22,25 @@ "0/31/65533": 1, "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/2": 4, @@ -71,8 +71,8 @@ "0/40/17": true, "0/40/18": "mock-lift-window-covering", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65533": 1, "0/40/65528": [], @@ -84,8 +84,8 @@ "0/48/2": 0, "0/48/3": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65533": 1, @@ -96,8 +96,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "MTI2MDk5", - "connected": true + "0": "MTI2MDk5", + "1": true } ], "0/49/2": 10, @@ -113,14 +113,14 @@ "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "JG8olrDo", - "IPv4Addresses": ["wKgBFw=="], - "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "JG8olrDo", + "5": ["wKgBFw=="], + "6": ["/oAAAAAAAAAmbyj//paw6A=="], + "7": 1 } ], "0/51/1": 1, @@ -141,47 +141,47 @@ "0/62/65532": 0, "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "", - "icac": null, - "fabricIndex": 2 + "1": "", + "2": null, + "254": 2 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", - "fabricIndex": 3 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "254": 3 } ], "0/62/2": 5, "0/62/3": 3, "0/62/1": [ { - "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", - "vendorId": 24582, - "fabricId": 7331465149450221740, - "nodeId": 3429688654, - "label": "", - "fabricIndex": 1 + "1": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "2": 24582, + "3": 7331465149450221740, + "4": 3429688654, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", - "vendorId": 4362, - "fabricId": 8516517930550670493, - "nodeId": 1443093566726981311, - "label": "", - "fabricIndex": 2 + "1": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "2": 4362, + "3": 8516517930550670493, + "4": 1443093566726981311, + "5": "", + "254": 2 }, { - "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", - "vendorId": 4939, - "fabricId": 2, - "nodeId": 50, - "label": "", - "fabricIndex": 3 + "1": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "2": 4939, + "3": 2, + "4": 50, + "5": "", + "254": 3 } ], "0/62/4": [ @@ -216,8 +216,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 514, - "revision": 2 + "0": 514, + "1": 2 } ], "1/29/1": [29, 3, 258], diff --git a/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json b/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json index 8d3335bbd6c..fe970b6ed6b 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json +++ b/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json @@ -7,8 +7,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -29,11 +29,11 @@ "0/30/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 2 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 2 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "7630EF9998EDF03C", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -117,8 +117,8 @@ "0/45/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -131,8 +131,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "TE9OR0FOLUlPVA==", - "connected": true + "0": "TE9OR0FOLUlPVA==", + "1": true } ], "0/49/2": 10, @@ -153,17 +153,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "hPcDB5/k", - "IPv4Addresses": ["wKgIhg=="], - "IPv6Addresses": [ - "/oAAAAAAAACG9wP//gef5A==", - "JA4DsgZ+bsCG9wP//gef5A==" - ], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "hPcDB5/k", + "5": ["wKgIhg=="], + "6": ["/oAAAAAAAACG9wP//gef5A==", "JA4DsgZ+bsCG9wP//gef5A=="], + "7": 1 } ], "0/51/1": 35, @@ -201,19 +198,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE5Rw88GvXEUXr+cPYgKd00rIWyiHM8eu4Bhrzf1v83yBI2Qa+pwfOsKyvzxiuHLMfzhdC3gre4najpimi8AsX+TcKNQEoARgkAgE2AwQCBAEYMAQUWh6NlHAMbG5gz+vqlF51fulr3z8wBRR+D1hE33RhFC/mJWrhhZs6SVStQBgwC0DD5IxVgOrftUA47K1bQHaCNuWqIxf/8oMfcI0nMvTtXApwbBAJI/LjjCwMZJVFBE3W/FC6dQWSEuF8ES745tLBGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEzpstYxy3lXF69g6H2vQ6uoqkdUsppJ4NcSyQcXQ8sQrF5HuzoVnDpevHfy0GAWHbXfE4VI0laTHvm/Wkj037ZjcKNQEpARgkAmAwBBR+D1hE33RhFC/mJWrhhZs6SVStQDAFFFCCK5NYv6CrD5/0S26zXBUwG0WBGDALQI5YKo3C3xvdqCrho2yZIJVJpJY2n9V/tmh7ESBBOHrY0b+K8Pf7hKhd5V0vzbCCbkhv1BNEne+lhcS2N6qhMNgY", - "fabricIndex": 2 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE5Rw88GvXEUXr+cPYgKd00rIWyiHM8eu4Bhrzf1v83yBI2Qa+pwfOsKyvzxiuHLMfzhdC3gre4najpimi8AsX+TcKNQEoARgkAgE2AwQCBAEYMAQUWh6NlHAMbG5gz+vqlF51fulr3z8wBRR+D1hE33RhFC/mJWrhhZs6SVStQBgwC0DD5IxVgOrftUA47K1bQHaCNuWqIxf/8oMfcI0nMvTtXApwbBAJI/LjjCwMZJVFBE3W/FC6dQWSEuF8ES745tLBGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEzpstYxy3lXF69g6H2vQ6uoqkdUsppJ4NcSyQcXQ8sQrF5HuzoVnDpevHfy0GAWHbXfE4VI0laTHvm/Wkj037ZjcKNQEpARgkAmAwBBR+D1hE33RhFC/mJWrhhZs6SVStQDAFFFCCK5NYv6CrD5/0S26zXBUwG0WBGDALQI5YKo3C3xvdqCrho2yZIJVJpJY2n9V/tmh7ESBBOHrY0b+K8Pf7hKhd5V0vzbCCbkhv1BNEne+lhcS2N6qhMNgY", + "254": 2 } ], "0/62/1": [ { - "rootPublicKey": "BFLMrM1satBpU0DN4sri/S4AVo/ugmZCndBfPO33Q+ZCKDZzNhMOB014+hZs0KL7vPssavT7Tb9nt0W+kpeAe0U=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 2 + "1": "BFLMrM1satBpU0DN4sri/S4AVo/ugmZCndBfPO33Q+ZCKDZzNhMOB014+hZs0KL7vPssavT7Tb9nt0W+kpeAe0U=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 2 } ], "0/62/2": 5, @@ -239,20 +236,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -281,8 +278,8 @@ "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 514, - "revision": 1 + "0": 514, + "1": 1 } ], "1/29/1": [3, 4, 29, 30, 64, 65, 258], @@ -301,20 +298,20 @@ "1/30/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "1/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json b/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json index 44347dbd964..92a1d820d2e 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json +++ b/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json @@ -8,8 +8,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], @@ -22,25 +22,25 @@ "0/31/65533": 1, "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/2": 4, @@ -71,8 +71,8 @@ "0/40/17": true, "0/40/18": "mock_pa_tilt_window_covering", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65533": 1, "0/40/65528": [], @@ -84,8 +84,8 @@ "0/48/2": 0, "0/48/3": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65533": 1, @@ -96,8 +96,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "MTI2MDk5", - "connected": true + "0": "MTI2MDk5", + "1": true } ], "0/49/2": 10, @@ -113,14 +113,14 @@ "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "JG8olrDo", - "IPv4Addresses": ["wKgBFw=="], - "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "JG8olrDo", + "5": ["wKgBFw=="], + "6": ["/oAAAAAAAAAmbyj//paw6A=="], + "7": 1 } ], "0/51/1": 1, @@ -141,47 +141,47 @@ "0/62/65532": 0, "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "", - "icac": null, - "fabricIndex": 2 + "1": "", + "2": null, + "254": 2 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", - "fabricIndex": 3 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "254": 3 } ], "0/62/2": 5, "0/62/3": 3, "0/62/1": [ { - "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", - "vendorId": 24582, - "fabricId": 7331465149450221740, - "nodeId": 3429688654, - "label": "", - "fabricIndex": 1 + "1": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "2": 24582, + "3": 7331465149450221740, + "4": 3429688654, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", - "vendorId": 4362, - "fabricId": 8516517930550670493, - "nodeId": 1443093566726981311, - "label": "", - "fabricIndex": 2 + "1": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "2": 4362, + "3": 8516517930550670493, + "4": 1443093566726981311, + "5": "", + "254": 2 }, { - "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", - "vendorId": 4939, - "fabricId": 2, - "nodeId": 50, - "label": "", - "fabricIndex": 3 + "1": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "2": 4939, + "3": 2, + "4": 50, + "5": "", + "254": 3 } ], "0/62/4": [ @@ -216,8 +216,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 514, - "revision": 2 + "0": 514, + "1": 2 } ], "1/29/1": [29, 3, 258], diff --git a/tests/components/matter/fixtures/nodes/window-covering_tilt.json b/tests/components/matter/fixtures/nodes/window-covering_tilt.json index a33e0f24c3f..144348b5c76 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_tilt.json +++ b/tests/components/matter/fixtures/nodes/window-covering_tilt.json @@ -8,8 +8,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], @@ -22,25 +22,25 @@ "0/31/65533": 1, "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/2": 4, @@ -71,8 +71,8 @@ "0/40/17": true, "0/40/18": "mock-tilt-window-covering", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65533": 1, "0/40/65528": [], @@ -84,8 +84,8 @@ "0/48/2": 0, "0/48/3": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65533": 1, @@ -96,8 +96,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "MTI2MDk5", - "connected": true + "0": "MTI2MDk5", + "1": true } ], "0/49/2": 10, @@ -113,14 +113,14 @@ "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "JG8olrDo", - "IPv4Addresses": ["wKgBFw=="], - "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "JG8olrDo", + "5": ["wKgBFw=="], + "6": ["/oAAAAAAAAAmbyj//paw6A=="], + "7": 1 } ], "0/51/1": 1, @@ -141,47 +141,47 @@ "0/62/65532": 0, "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "", - "icac": null, - "fabricIndex": 2 + "1": "", + "2": null, + "254": 2 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", - "fabricIndex": 3 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "254": 3 } ], "0/62/2": 5, "0/62/3": 3, "0/62/1": [ { - "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", - "vendorId": 24582, - "fabricId": 7331465149450221740, - "nodeId": 3429688654, - "label": "", - "fabricIndex": 1 + "1": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "2": 24582, + "3": 7331465149450221740, + "4": 3429688654, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", - "vendorId": 4362, - "fabricId": 8516517930550670493, - "nodeId": 1443093566726981311, - "label": "", - "fabricIndex": 2 + "1": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "2": 4362, + "3": 8516517930550670493, + "4": 1443093566726981311, + "5": "", + "254": 2 }, { - "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", - "vendorId": 4939, - "fabricId": 2, - "nodeId": 50, - "label": "", - "fabricIndex": 3 + "1": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "2": 4939, + "3": 2, + "4": 50, + "5": "", + "254": 3 } ], "0/62/4": [ @@ -216,8 +216,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 514, - "revision": 2 + "0": 514, + "1": 2 } ], "1/29/1": [29, 3, 258], diff --git a/tests/components/matter/test_adapter.py b/tests/components/matter/test_adapter.py index 8ed309f61df..35e6673114e 100644 --- a/tests/components/matter/test_adapter.py +++ b/tests/components/matter/test_adapter.py @@ -145,9 +145,12 @@ async def test_node_added_subscription( ) -> None: """Test subscription to new devices work.""" assert matter_client.subscribe_events.call_count == 4 - assert matter_client.subscribe_events.call_args[0][1] == EventType.NODE_ADDED + assert ( + matter_client.subscribe_events.call_args.kwargs["event_filter"] + == EventType.NODE_ADDED + ) - node_added_callback = matter_client.subscribe_events.call_args[0][0] + node_added_callback = matter_client.subscribe_events.call_args.kwargs["callback"] node_data = load_and_parse_node_fixture("onoff-light") node = MatterNode( dataclass_from_dict( diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index 4dbb3b27b9c..e231012f90d 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -90,6 +90,7 @@ async def test_occupancy_sensor( @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_battery_sensor( hass: HomeAssistant, + entity_registry: er.EntityRegistry, matter_client: MagicMock, door_lock: MatterNode, ) -> None: @@ -108,7 +109,6 @@ async def test_battery_sensor( assert state assert state.state == "on" - entity_registry = er.async_get(hass) entry = entity_registry.async_get(entity_id) assert entry diff --git a/tests/components/matter/test_diagnostics.py b/tests/components/matter/test_diagnostics.py index 303e9879c56..c14eb93f24c 100644 --- a/tests/components/matter/test_diagnostics.py +++ b/tests/components/matter/test_diagnostics.py @@ -81,6 +81,7 @@ async def test_config_entry_diagnostics( async def test_device_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, matter_client: MagicMock, config_entry_diagnostics: dict[str, Any], device_diagnostics: dict[str, Any], @@ -102,8 +103,9 @@ async def test_device_diagnostics( ) matter_client.get_diagnostics.return_value = server_diagnostics config_entry = hass.config_entries.async_entries(DOMAIN)[0] - dev_reg = dr.async_get(hass) - device = dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id)[0] + device = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)[ + 0 + ] assert device diagnostics = await get_diagnostics_for_device( diff --git a/tests/components/matter/test_helpers.py b/tests/components/matter/test_helpers.py index 36761362618..61988a37122 100644 --- a/tests/components/matter/test_helpers.py +++ b/tests/components/matter/test_helpers.py @@ -37,10 +37,10 @@ async def test_get_device_id( @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_get_node_from_device_entry( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, matter_client: MagicMock, ) -> None: """Test get_node_from_device_entry.""" - device_registry = dr.async_get(hass) other_domain = "other_domain" other_config_entry = MockConfigEntry(domain=other_domain) other_config_entry.add_to_hass(hass) @@ -56,20 +56,17 @@ async def test_get_node_from_device_entry( device_registry, config_entry.entry_id )[0] assert device_entry - node_from_device_entry = await get_node_from_device_entry(hass, device_entry) + node_from_device_entry = get_node_from_device_entry(hass, device_entry) assert node_from_device_entry is node - with pytest.raises(ValueError) as value_error: - await get_node_from_device_entry(hass, other_device_entry) - - assert f"Device {other_device_entry.id} is not a Matter device" in str( - value_error.value - ) + # test non-Matter device returns None + assert get_node_from_device_entry(hass, other_device_entry) is None matter_client.server_info = None + # test non-initialized server raises RuntimeError with pytest.raises(RuntimeError) as runtime_error: - node_from_device_entry = await get_node_from_device_entry(hass, device_entry) + node_from_device_entry = get_node_from_device_entry(hass, device_entry) assert "Matter server information is not available" in str(runtime_error.value) diff --git a/tests/components/matter/test_init.py b/tests/components/matter/test_init.py index bbe77b76af5..2286249bd5d 100644 --- a/tests/components/matter/test_init.py +++ b/tests/components/matter/test_init.py @@ -612,6 +612,8 @@ async def test_remove_entry( @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_remove_config_entry_device( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, matter_client: MagicMock, hass_ws_client: WebSocketGenerator, ) -> None: @@ -621,11 +623,9 @@ async def test_remove_config_entry_device( await hass.async_block_till_done() config_entry = hass.config_entries.async_entries(DOMAIN)[0] - device_registry = dr.async_get(hass) device_entry = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id )[0] - entity_registry = er.async_get(hass) entity_id = "light.m5stamp_lighting_app" assert device_entry @@ -654,6 +654,7 @@ async def test_remove_config_entry_device( @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_remove_config_entry_device_no_node( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, matter_client: MagicMock, integration: MockConfigEntry, hass_ws_client: WebSocketGenerator, @@ -661,7 +662,6 @@ async def test_remove_config_entry_device_no_node( """Test that a device can be removed ok without an existing node.""" assert await async_setup_component(hass, "config", {}) config_entry = integration - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={ diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 2650f2b1a6f..5b343b8c4e5 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -1,5 +1,6 @@ """Test Matter sensors.""" -from unittest.mock import MagicMock +from datetime import UTC, datetime, timedelta +from unittest.mock import MagicMock, patch from matter_server.client.models.node import MatterNode import pytest @@ -14,6 +15,8 @@ from .common import ( trigger_subscription_callback, ) +from tests.common import async_fire_time_changed + @pytest.fixture(name="flow_sensor_node") async def flow_sensor_node_fixture( @@ -63,6 +66,16 @@ async def temperature_sensor_node_fixture( ) +@pytest.fixture(name="eve_energy_plug_node") +async def eve_energy_plug_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a Eve Energy Plug node.""" + return await setup_integration_with_node_fixture( + hass, "eve-energy-plug", matter_client + ) + + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_sensor_null_value( @@ -187,6 +200,7 @@ async def test_temperature_sensor( @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_battery_sensor( hass: HomeAssistant, + entity_registry: er.EntityRegistry, matter_client: MagicMock, eve_contact_sensor_node: MatterNode, ) -> None: @@ -203,8 +217,74 @@ async def test_battery_sensor( assert state assert state.state == "50" - entity_registry = er.async_get(hass) entry = entity_registry.async_get(entity_id) assert entry assert entry.entity_category == EntityCategory.DIAGNOSTIC + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_eve_energy_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + matter_client: MagicMock, + eve_energy_plug_node: MatterNode, +) -> None: + """Test Energy sensors created from Eve Energy custom cluster.""" + # power sensor + entity_id = "sensor.eve_energy_plug_power" + state = hass.states.get(entity_id) + assert state + assert state.state == "0.0" + assert state.attributes["unit_of_measurement"] == "W" + assert state.attributes["device_class"] == "power" + assert state.attributes["friendly_name"] == "Eve Energy Plug Power" + + # voltage sensor + entity_id = "sensor.eve_energy_plug_voltage" + state = hass.states.get(entity_id) + assert state + assert state.state == "238.800003051758" + assert state.attributes["unit_of_measurement"] == "V" + assert state.attributes["device_class"] == "voltage" + assert state.attributes["friendly_name"] == "Eve Energy Plug Voltage" + + # energy sensor + entity_id = "sensor.eve_energy_plug_energy" + state = hass.states.get(entity_id) + assert state + assert state.state == "0.220000028610229" + assert state.attributes["unit_of_measurement"] == "kWh" + assert state.attributes["device_class"] == "energy" + assert state.attributes["friendly_name"] == "Eve Energy Plug Energy" + assert state.attributes["state_class"] == "total_increasing" + + # current sensor + entity_id = "sensor.eve_energy_plug_current" + state = hass.states.get(entity_id) + assert state + assert state.state == "0.0" + assert state.attributes["unit_of_measurement"] == "A" + assert state.attributes["device_class"] == "current" + assert state.attributes["friendly_name"] == "Eve Energy Plug Current" + + # test if the sensor gets polled on interval + eve_energy_plug_node.update_attribute("1/319486977/319422472", 237.0) + async_fire_time_changed(hass, datetime.now(UTC) + timedelta(seconds=31)) + await hass.async_block_till_done() + entity_id = "sensor.eve_energy_plug_voltage" + state = hass.states.get(entity_id) + assert state + assert state.state == "237.0" + + # test extra poll triggered when secondary value (switch state) changes + set_node_attribute(eve_energy_plug_node, 1, 6, 0, True) + eve_energy_plug_node.update_attribute("1/319486977/319422474", 5.0) + with patch("homeassistant.components.matter.entity.EXTRA_POLL_DELAY", 0.0): + await trigger_subscription_callback(hass, matter_client) + await hass.async_block_till_done() + entity_id = "sensor.eve_energy_plug_power" + state = hass.states.get(entity_id) + assert state + assert state.state == "5.0" diff --git a/tests/components/maxcube/test_maxcube_binary_sensor.py b/tests/components/maxcube/test_maxcube_binary_sensor.py index 65991f91b7b..0c73c548211 100644 --- a/tests/components/maxcube/test_maxcube_binary_sensor.py +++ b/tests/components/maxcube/test_maxcube_binary_sensor.py @@ -23,10 +23,12 @@ BATTERY_ENTITY_ID = f"{ENTITY_ID}_battery" async def test_window_shuttler( - hass: HomeAssistant, cube: MaxCube, windowshutter: MaxWindowShutter + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + cube: MaxCube, + windowshutter: MaxWindowShutter, ) -> None: """Test a successful setup with a shuttler device.""" - entity_registry = er.async_get(hass) assert entity_registry.async_is_registered(ENTITY_ID) entity = entity_registry.async_get(ENTITY_ID) assert entity.unique_id == "AABBCCDD03" @@ -47,10 +49,12 @@ async def test_window_shuttler( async def test_window_shuttler_battery( - hass: HomeAssistant, cube: MaxCube, windowshutter: MaxWindowShutter + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + cube: MaxCube, + windowshutter: MaxWindowShutter, ) -> None: """Test battery binary_state with a shuttler device.""" - entity_registry = er.async_get(hass) assert entity_registry.async_is_registered(BATTERY_ENTITY_ID) entity = entity_registry.async_get(BATTERY_ENTITY_ID) assert entity.unique_id == "AABBCCDD03_battery" diff --git a/tests/components/maxcube/test_maxcube_climate.py b/tests/components/maxcube/test_maxcube_climate.py index 3682c98e947..f279f049ac3 100644 --- a/tests/components/maxcube/test_maxcube_climate.py +++ b/tests/components/maxcube/test_maxcube_climate.py @@ -60,9 +60,10 @@ WALL_ENTITY_ID = "climate.testroom_testwallthermostat" VALVE_POSITION = "valve_position" -async def test_setup_thermostat(hass: HomeAssistant, cube: MaxCube) -> None: +async def test_setup_thermostat( + hass: HomeAssistant, entity_registry: er.EntityRegistry, cube: MaxCube +) -> None: """Test a successful setup of a thermostat device.""" - entity_registry = er.async_get(hass) assert entity_registry.async_is_registered(ENTITY_ID) entity = entity_registry.async_get(ENTITY_ID) assert entity.unique_id == "AABBCCDD01" @@ -96,9 +97,10 @@ async def test_setup_thermostat(hass: HomeAssistant, cube: MaxCube) -> None: assert state.attributes.get(VALVE_POSITION) == 25 -async def test_setup_wallthermostat(hass: HomeAssistant, cube: MaxCube) -> None: +async def test_setup_wallthermostat( + hass: HomeAssistant, entity_registry: er.EntityRegistry, cube: MaxCube +) -> None: """Test a successful setup of a wall thermostat device.""" - entity_registry = er.async_get(hass) assert entity_registry.async_is_registered(WALL_ENTITY_ID) entity = entity_registry.async_get(WALL_ENTITY_ID) assert entity.unique_id == "AABBCCDD02" diff --git a/tests/components/met/__init__.py b/tests/components/met/__init__.py index 0a17b415965..2ef0f7e12f0 100644 --- a/tests/components/met/__init__.py +++ b/tests/components/met/__init__.py @@ -21,7 +21,7 @@ async def init_integration(hass, track_home=False) -> MockConfigEntry: entry = MockConfigEntry(domain=DOMAIN, data=entry_data) with patch( - "homeassistant.components.met.metno.MetWeatherData.fetching_data", + "homeassistant.components.met.coordinator.metno.MetWeatherData.fetching_data", return_value=True, ): entry.add_to_hass(hass) diff --git a/tests/components/met/test_config_flow.py b/tests/components/met/test_config_flow.py index 59ffff14f1b..24ce8660346 100644 --- a/tests/components/met/test_config_flow.py +++ b/tests/components/met/test_config_flow.py @@ -156,7 +156,9 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert result["step_id"] == "init" # Test Options flow updated config entry - with patch("homeassistant.components.met.metno.MetWeatherData") as weatherdatamock: + with patch( + "homeassistant.components.met.coordinator.metno.MetWeatherData" + ) as weatherdatamock: result = await hass.config_entries.options.async_init( entry.entry_id, data=update_data ) diff --git a/tests/components/met/test_init.py b/tests/components/met/test_init.py index 652763947df..0e4e46b09da 100644 --- a/tests/components/met/test_init.py +++ b/tests/components/met/test_init.py @@ -52,13 +52,15 @@ async def test_fail_default_home_entry( async def test_removing_incorrect_devices( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_weather + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + mock_weather, ) -> None: """Test we remove incorrect devices.""" entry = await init_integration(hass) - device_reg = dr.async_get(hass) - device_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=entry.entry_id, name="Forecast_legacy", entry_type=dr.DeviceEntryType.SERVICE, @@ -71,6 +73,6 @@ async def test_removing_incorrect_devices( assert await hass.config_entries.async_reload(entry.entry_id) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert not device_reg.async_get_device(identifiers={(DOMAIN,)}) - assert device_reg.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) + assert not device_registry.async_get_device(identifiers={(DOMAIN,)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) assert "Removing improper device Forecast_legacy" in caplog.text diff --git a/tests/components/met/test_weather.py b/tests/components/met/test_weather.py index 5a28b8eceb0..432c288383a 100644 --- a/tests/components/met/test_weather.py +++ b/tests/components/met/test_weather.py @@ -6,21 +6,23 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -async def test_new_config_entry(hass: HomeAssistant, mock_weather) -> None: +async def test_new_config_entry( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_weather +) -> None: """Test the expected entities are created.""" - registry = er.async_get(hass) await hass.config_entries.flow.async_init("met", context={"source": "onboarding"}) await hass.async_block_till_done() assert len(hass.states.async_entity_ids("weather")) == 1 entry = hass.config_entries.async_entries()[0] - assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 1 + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 1 -async def test_legacy_config_entry(hass: HomeAssistant, mock_weather) -> None: +async def test_legacy_config_entry( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_weather +) -> None: """Test the expected entities are created.""" - registry = er.async_get(hass) - registry.async_get_or_create( + entity_registry.async_get_or_create( WEATHER_DOMAIN, DOMAIN, "home-hourly", @@ -30,7 +32,7 @@ async def test_legacy_config_entry(hass: HomeAssistant, mock_weather) -> None: assert len(hass.states.async_entity_ids("weather")) == 2 entry = hass.config_entries.async_entries()[0] - assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 2 + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 2 async def test_tracking_home(hass: HomeAssistant, mock_weather) -> None: diff --git a/tests/components/met_eireann/snapshots/test_weather.ambr b/tests/components/met_eireann/snapshots/test_weather.ambr index 81d7a52aa06..90f36d09d25 100644 --- a/tests/components/met_eireann/snapshots/test_weather.ambr +++ b/tests/components/met_eireann/snapshots/test_weather.ambr @@ -31,6 +31,110 @@ ]), }) # --- +# name: test_forecast_service[forecast] + dict({ + 'weather.somewhere': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-08T12:00:00+00:00', + 'temperature': 10.0, + }), + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-09T12:00:00+00:00', + 'temperature': 20.0, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast].1 + dict({ + 'weather.somewhere': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-08T12:00:00+00:00', + 'temperature': 10.0, + }), + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-09T12:00:00+00:00', + 'temperature': 20.0, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecast] + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-08T12:00:00+00:00', + 'temperature': 10.0, + }), + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-09T12:00:00+00:00', + 'temperature': 20.0, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecast].1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-08T12:00:00+00:00', + 'temperature': 10.0, + }), + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-09T12:00:00+00:00', + 'temperature': 20.0, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecasts] + dict({ + 'weather.somewhere': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-08T12:00:00+00:00', + 'temperature': 10.0, + }), + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-09T12:00:00+00:00', + 'temperature': 20.0, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecasts].1 + dict({ + 'weather.somewhere': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-08T12:00:00+00:00', + 'temperature': 10.0, + }), + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-09T12:00:00+00:00', + 'temperature': 20.0, + }), + ]), + }), + }) +# --- # name: test_forecast_subscription[daily] list([ dict({ diff --git a/tests/components/met_eireann/test_weather.py b/tests/components/met_eireann/test_weather.py index a3ca1fd55f7..e5c2c66b626 100644 --- a/tests/components/met_eireann/test_weather.py +++ b/tests/components/met_eireann/test_weather.py @@ -9,7 +9,8 @@ from homeassistant.components.met_eireann import UPDATE_INTERVAL from homeassistant.components.met_eireann.const import DOMAIN from homeassistant.components.weather import ( DOMAIN as WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + LEGACY_SERVICE_GET_FORECAST, + SERVICE_GET_FORECASTS, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -32,20 +33,22 @@ async def setup_config_entry(hass: HomeAssistant) -> ConfigEntry: return mock_data -async def test_new_config_entry(hass: HomeAssistant, mock_weather) -> None: +async def test_new_config_entry( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_weather +) -> None: """Test the expected entities are created.""" - registry = er.async_get(hass) await setup_config_entry(hass) assert len(hass.states.async_entity_ids("weather")) == 1 entry = hass.config_entries.async_entries()[0] - assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 1 + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 1 -async def test_legacy_config_entry(hass: HomeAssistant, mock_weather) -> None: +async def test_legacy_config_entry( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_weather +) -> None: """Test the expected entities are created.""" - registry = er.async_get(hass) - registry.async_get_or_create( + entity_registry.async_get_or_create( WEATHER_DOMAIN, DOMAIN, "10-20-hourly", @@ -54,7 +57,7 @@ async def test_legacy_config_entry(hass: HomeAssistant, mock_weather) -> None: assert len(hass.states.async_entity_ids("weather")) == 2 entry = hass.config_entries.async_entries()[0] - assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 2 + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 2 async def test_weather(hass: HomeAssistant, mock_weather) -> None: @@ -75,10 +78,18 @@ async def test_weather(hass: HomeAssistant, mock_weather) -> None: assert len(hass.states.async_entity_ids("weather")) == 0 +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) async def test_forecast_service( hass: HomeAssistant, mock_weather, snapshot: SnapshotAssertion, + service: str, ) -> None: """Test multiple forecast.""" mock_weather.get_forecast.return_value = [ @@ -100,7 +111,7 @@ async def test_forecast_service( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": entity_id, "type": "daily", @@ -112,7 +123,7 @@ async def test_forecast_service( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": entity_id, "type": "hourly", diff --git a/tests/components/metoffice/snapshots/test_weather.ambr b/tests/components/metoffice/snapshots/test_weather.ambr index 38df9f04ab2..108a9330403 100644 --- a/tests/components/metoffice/snapshots/test_weather.ambr +++ b/tests/components/metoffice/snapshots/test_weather.ambr @@ -647,6 +647,1988 @@ ]), }) # --- +# name: test_forecast_service[forecast] + dict({ + 'weather.met_office_wavertree_daily': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 13.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast].1 + dict({ + 'weather.met_office_wavertree_daily': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-25T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 19.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T18:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 17.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 14.0, + 'wind_bearing': 'NW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T00:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 13.0, + 'wind_bearing': 'WSW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T03:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T09:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T18:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T00:00:00+00:00', + 'precipitation_probability': 11, + 'temperature': 9.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T03:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 8.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T06:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 8.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 4, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T18:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-27T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T00:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 8.0, + 'wind_bearing': 'NNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 7.0, + 'wind_bearing': 'W', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-28T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 6.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-28T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T15:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T18:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NNE', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T00:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'E', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-29T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 8.0, + 'wind_bearing': 'SSE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T06:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 8.0, + 'wind_bearing': 'SE', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T09:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 10.0, + 'wind_bearing': 'SE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 47, + 'temperature': 12.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2020-04-29T15:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T18:00:00+00:00', + 'precipitation_probability': 39, + 'temperature': 12.0, + 'wind_bearing': 'SSE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T21:00:00+00:00', + 'precipitation_probability': 19, + 'temperature': 11.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast].2 + dict({ + 'weather.met_office_wavertree_daily': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 13.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast].3 + dict({ + 'weather.met_office_wavertree_daily': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-25T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 19.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T18:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 17.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 14.0, + 'wind_bearing': 'NW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T00:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 13.0, + 'wind_bearing': 'WSW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T03:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T09:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T18:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T00:00:00+00:00', + 'precipitation_probability': 11, + 'temperature': 9.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T03:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 8.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T06:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 8.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 4, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T18:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-27T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T00:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 8.0, + 'wind_bearing': 'NNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 7.0, + 'wind_bearing': 'W', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-28T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 6.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-28T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T15:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T18:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NNE', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T00:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'E', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-29T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 8.0, + 'wind_bearing': 'SSE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T06:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 8.0, + 'wind_bearing': 'SE', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T09:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 10.0, + 'wind_bearing': 'SE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 47, + 'temperature': 12.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2020-04-29T15:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T18:00:00+00:00', + 'precipitation_probability': 39, + 'temperature': 12.0, + 'wind_bearing': 'SSE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T21:00:00+00:00', + 'precipitation_probability': 19, + 'temperature': 11.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast].4 + dict({ + 'weather.met_office_wavertree_daily': dict({ + 'forecast': list([ + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecast] + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 13.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecast].1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-25T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 19.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T18:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 17.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 14.0, + 'wind_bearing': 'NW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T00:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 13.0, + 'wind_bearing': 'WSW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T03:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T09:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T18:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T00:00:00+00:00', + 'precipitation_probability': 11, + 'temperature': 9.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T03:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 8.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T06:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 8.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 4, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T18:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-27T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T00:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 8.0, + 'wind_bearing': 'NNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 7.0, + 'wind_bearing': 'W', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-28T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 6.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-28T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T15:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T18:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NNE', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T00:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'E', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-29T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 8.0, + 'wind_bearing': 'SSE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T06:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 8.0, + 'wind_bearing': 'SE', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T09:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 10.0, + 'wind_bearing': 'SE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 47, + 'temperature': 12.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2020-04-29T15:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T18:00:00+00:00', + 'precipitation_probability': 39, + 'temperature': 12.0, + 'wind_bearing': 'SSE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T21:00:00+00:00', + 'precipitation_probability': 19, + 'temperature': 11.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecast].2 + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 13.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecast].3 + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-25T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 19.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T18:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 17.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 14.0, + 'wind_bearing': 'NW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T00:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 13.0, + 'wind_bearing': 'WSW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T03:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T09:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T18:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T00:00:00+00:00', + 'precipitation_probability': 11, + 'temperature': 9.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T03:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 8.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T06:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 8.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 4, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T18:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-27T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T00:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 8.0, + 'wind_bearing': 'NNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 7.0, + 'wind_bearing': 'W', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-28T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 6.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-28T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T15:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T18:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NNE', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T00:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'E', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-29T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 8.0, + 'wind_bearing': 'SSE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T06:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 8.0, + 'wind_bearing': 'SE', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T09:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 10.0, + 'wind_bearing': 'SE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 47, + 'temperature': 12.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2020-04-29T15:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T18:00:00+00:00', + 'precipitation_probability': 39, + 'temperature': 12.0, + 'wind_bearing': 'SSE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T21:00:00+00:00', + 'precipitation_probability': 19, + 'temperature': 11.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecast].4 + dict({ + 'forecast': list([ + ]), + }) +# --- +# name: test_forecast_service[get_forecasts] + dict({ + 'weather.met_office_wavertree_daily': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 13.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecasts].1 + dict({ + 'weather.met_office_wavertree_daily': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-25T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 19.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T18:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 17.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 14.0, + 'wind_bearing': 'NW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T00:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 13.0, + 'wind_bearing': 'WSW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T03:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T09:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T18:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T00:00:00+00:00', + 'precipitation_probability': 11, + 'temperature': 9.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T03:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 8.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T06:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 8.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 4, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T18:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-27T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T00:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 8.0, + 'wind_bearing': 'NNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 7.0, + 'wind_bearing': 'W', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-28T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 6.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-28T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T15:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T18:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NNE', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T00:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'E', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-29T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 8.0, + 'wind_bearing': 'SSE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T06:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 8.0, + 'wind_bearing': 'SE', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T09:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 10.0, + 'wind_bearing': 'SE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 47, + 'temperature': 12.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2020-04-29T15:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T18:00:00+00:00', + 'precipitation_probability': 39, + 'temperature': 12.0, + 'wind_bearing': 'SSE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T21:00:00+00:00', + 'precipitation_probability': 19, + 'temperature': 11.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecasts].2 + dict({ + 'weather.met_office_wavertree_daily': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 13.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecasts].3 + dict({ + 'weather.met_office_wavertree_daily': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-25T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 19.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T18:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 17.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 14.0, + 'wind_bearing': 'NW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T00:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 13.0, + 'wind_bearing': 'WSW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T03:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T09:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T18:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T00:00:00+00:00', + 'precipitation_probability': 11, + 'temperature': 9.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T03:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 8.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T06:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 8.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 4, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T18:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-27T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T00:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 8.0, + 'wind_bearing': 'NNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 7.0, + 'wind_bearing': 'W', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-28T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 6.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-28T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T15:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T18:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NNE', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T00:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'E', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-29T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 8.0, + 'wind_bearing': 'SSE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T06:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 8.0, + 'wind_bearing': 'SE', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T09:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 10.0, + 'wind_bearing': 'SE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 47, + 'temperature': 12.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2020-04-29T15:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T18:00:00+00:00', + 'precipitation_probability': 39, + 'temperature': 12.0, + 'wind_bearing': 'SSE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T21:00:00+00:00', + 'precipitation_probability': 19, + 'temperature': 11.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecasts].4 + dict({ + 'weather.met_office_wavertree_daily': dict({ + 'forecast': list([ + ]), + }), + }) +# --- # name: test_forecast_subscription[weather.met_office_wavertree_3_hourly] list([ dict({ diff --git a/tests/components/metoffice/test_init.py b/tests/components/metoffice/test_init.py index a9e286907d5..10ed0a83f0c 100644 --- a/tests/components/metoffice/test_init.py +++ b/tests/components/metoffice/test_init.py @@ -89,6 +89,7 @@ from tests.common import MockConfigEntry ) async def test_migrate_unique_id( hass: HomeAssistant, + entity_registry: er.EntityRegistry, old_unique_id: str, new_unique_id: str, migration_needed: bool, @@ -102,9 +103,7 @@ async def test_migrate_unique_id( ) entry.add_to_hass(hass) - ent_reg = er.async_get(hass) - - entity: er.RegistryEntry = ent_reg.async_get_or_create( + entity: er.RegistryEntry = entity_registry.async_get_or_create( suggested_object_id="my_sensor", disabled_by=None, domain=SENSOR_DOMAIN, @@ -118,9 +117,12 @@ async def test_migrate_unique_id( await hass.async_block_till_done() if migration_needed: - assert ent_reg.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id) is None + assert ( + entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id) + is None + ) assert ( - ent_reg.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, new_unique_id) + entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, new_unique_id) == "sensor.my_sensor" ) diff --git a/tests/components/metoffice/test_weather.py b/tests/components/metoffice/test_weather.py index 6c6041b1869..19c27873d5e 100644 --- a/tests/components/metoffice/test_weather.py +++ b/tests/components/metoffice/test_weather.py @@ -13,7 +13,8 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.metoffice.const import DEFAULT_SCAN_INTERVAL, DOMAIN from homeassistant.components.weather import ( DOMAIN as WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + LEGACY_SERVICE_GET_FORECAST, + SERVICE_GET_FORECASTS, ) from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -100,13 +101,15 @@ async def test_site_cannot_connect( @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_site_cannot_update( - hass: HomeAssistant, requests_mock: requests_mock.Mocker, wavertree_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + requests_mock: requests_mock.Mocker, + wavertree_data, ) -> None: """Test we handle cannot connect error.""" - registry = er.async_get(hass) # Pre-create the hourly entity - registry.async_get_or_create( + entity_registry.async_get_or_create( WEATHER_DOMAIN, DOMAIN, "53.38374_-2.90929", @@ -143,13 +146,15 @@ async def test_site_cannot_update( @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_one_weather_site_running( - hass: HomeAssistant, requests_mock: requests_mock.Mocker, wavertree_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + requests_mock: requests_mock.Mocker, + wavertree_data, ) -> None: """Test the Met Office weather platform.""" - registry = er.async_get(hass) # Pre-create the hourly entity - registry.async_get_or_create( + entity_registry.async_get_or_create( WEATHER_DOMAIN, DOMAIN, "53.38374_-2.90929", @@ -219,19 +224,21 @@ async def test_one_weather_site_running( @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_two_weather_sites_running( - hass: HomeAssistant, requests_mock: requests_mock.Mocker, wavertree_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + requests_mock: requests_mock.Mocker, + wavertree_data, ) -> None: """Test we handle two different weather sites both running.""" - registry = er.async_get(hass) # Pre-create the hourly entities - registry.async_get_or_create( + entity_registry.async_get_or_create( WEATHER_DOMAIN, DOMAIN, "53.38374_-2.90929", suggested_object_id="met_office_wavertree_3_hourly", ) - registry.async_get_or_create( + entity_registry.async_get_or_create( WEATHER_DOMAIN, DOMAIN, "52.75556_0.44231", @@ -369,9 +376,10 @@ async def test_two_weather_sites_running( @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) -async def test_new_config_entry(hass: HomeAssistant, no_sensor, wavertree_data) -> None: +async def test_new_config_entry( + hass: HomeAssistant, entity_registry: er.EntityRegistry, no_sensor, wavertree_data +) -> None: """Test the expected entities are created.""" - registry = er.async_get(hass) entry = MockConfigEntry( domain=DOMAIN, @@ -383,17 +391,16 @@ async def test_new_config_entry(hass: HomeAssistant, no_sensor, wavertree_data) assert len(hass.states.async_entity_ids(WEATHER_DOMAIN)) == 1 entry = hass.config_entries.async_entries()[0] - assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 1 + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 1 @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_legacy_config_entry( - hass: HomeAssistant, no_sensor, wavertree_data + hass: HomeAssistant, entity_registry: er.EntityRegistry, no_sensor, wavertree_data ) -> None: """Test the expected entities are created.""" - registry = er.async_get(hass) # Pre-create the hourly entity - registry.async_get_or_create( + entity_registry.async_get_or_create( WEATHER_DOMAIN, DOMAIN, "53.38374_-2.90929", @@ -411,10 +418,17 @@ async def test_legacy_config_entry( assert len(hass.states.async_entity_ids("weather")) == 2 entry = hass.config_entries.async_entries()[0] - assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 2 + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 2 @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) async def test_forecast_service( hass: HomeAssistant, freezer: FrozenDateTimeFactory, @@ -422,6 +436,7 @@ async def test_forecast_service( snapshot: SnapshotAssertion, no_sensor, wavertree_data: dict[str, _Matcher], + service: str, ) -> None: """Test multiple forecast.""" entry = MockConfigEntry( @@ -438,7 +453,7 @@ async def test_forecast_service( for forecast_type in ("daily", "hourly"): response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.met_office_wavertree_daily", "type": forecast_type, @@ -446,7 +461,6 @@ async def test_forecast_service( blocking=True, return_response=True, ) - assert response["forecast"] != [] assert response == snapshot # Calling the services should use cached data @@ -464,7 +478,7 @@ async def test_forecast_service( for forecast_type in ("daily", "hourly"): response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.met_office_wavertree_daily", "type": forecast_type, @@ -472,7 +486,6 @@ async def test_forecast_service( blocking=True, return_response=True, ) - assert response["forecast"] != [] assert response == snapshot # Calling the services should update the hourly forecast @@ -488,7 +501,7 @@ async def test_forecast_service( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.met_office_wavertree_daily", "type": "hourly", @@ -496,7 +509,7 @@ async def test_forecast_service( blocking=True, return_response=True, ) - assert response["forecast"] == [] + assert response == snapshot @pytest.mark.parametrize( @@ -510,6 +523,7 @@ async def test_forecast_service( async def test_forecast_subscription( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, no_sensor, @@ -519,9 +533,8 @@ async def test_forecast_subscription( """Test multiple forecast.""" client = await hass_ws_client(hass) - registry = er.async_get(hass) # Pre-create the hourly entity - registry.async_get_or_create( + entity_registry.async_get_or_create( WEATHER_DOMAIN, DOMAIN, "53.38374_-2.90929", @@ -559,6 +572,7 @@ async def test_forecast_subscription( assert forecast1 == snapshot freezer.tick(DEFAULT_SCAN_INTERVAL + timedelta(seconds=1)) + async_fire_time_changed(hass) await hass.async_block_till_done() msg = await client.receive_json() @@ -575,5 +589,8 @@ async def test_forecast_subscription( "subscription": subscription_id, } ) + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() msg = await client.receive_json() assert msg["success"] diff --git a/tests/components/microsoft/test_tts.py b/tests/components/microsoft/test_tts.py index 9684d1aa7d5..bc6a3ac7dd7 100644 --- a/tests/components/microsoft/test_tts.py +++ b/tests/components/microsoft/test_tts.py @@ -1,10 +1,11 @@ """Tests for Microsoft text-to-speech.""" +from http import HTTPStatus from unittest.mock import patch from pycsspeechtts import pycsspeechtts import pytest -from homeassistant.components import media_source, tts +from homeassistant.components import tts from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, DOMAIN as DOMAIN_MP, @@ -13,19 +14,12 @@ from homeassistant.components.media_player import ( from homeassistant.components.microsoft.tts import SUPPORTED_LANGUAGES from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceNotFound +from homeassistant.exceptions import ServiceNotFound from homeassistant.setup import async_setup_component from tests.common import async_mock_service - - -async def get_media_source_url(hass: HomeAssistant, media_content_id): - """Get the media source url.""" - if media_source.DOMAIN not in hass.config.components: - assert await async_setup_component(hass, media_source.DOMAIN, {}) - - resolved = await media_source.async_resolve_media(hass, media_content_id, None) - return resolved.url +from tests.components.tts.common import retrieve_media +from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) @@ -58,7 +52,9 @@ def mock_tts(): yield mock_tts -async def test_service_say(hass: HomeAssistant, mock_tts, calls) -> None: +async def test_service_say( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts, calls +) -> None: """Test service call say.""" await async_setup_component( @@ -76,9 +72,12 @@ async def test_service_say(hass: HomeAssistant, mock_tts, calls) -> None: ) assert len(calls) == 1 - url = await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + assert len(mock_tts.mock_calls) == 2 - assert url.endswith(".mp3") assert mock_tts.mock_calls[1][2] == { "language": "en-us", @@ -93,7 +92,9 @@ async def test_service_say(hass: HomeAssistant, mock_tts, calls) -> None: } -async def test_service_say_en_gb_config(hass: HomeAssistant, mock_tts, calls) -> None: +async def test_service_say_en_gb_config( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts, calls +) -> None: """Test service call say with en-gb code in the config.""" await async_setup_component( @@ -120,7 +121,11 @@ async def test_service_say_en_gb_config(hass: HomeAssistant, mock_tts, calls) -> ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + assert len(mock_tts.mock_calls) == 2 assert mock_tts.mock_calls[1][2] == { "language": "en-gb", @@ -135,7 +140,9 @@ async def test_service_say_en_gb_config(hass: HomeAssistant, mock_tts, calls) -> } -async def test_service_say_en_gb_service(hass: HomeAssistant, mock_tts, calls) -> None: +async def test_service_say_en_gb_service( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts, calls +) -> None: """Test service call say with en-gb code in the service.""" await async_setup_component( @@ -157,7 +164,11 @@ async def test_service_say_en_gb_service(hass: HomeAssistant, mock_tts, calls) - ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + assert len(mock_tts.mock_calls) == 2 assert mock_tts.mock_calls[1][2] == { "language": "en-gb", @@ -172,7 +183,9 @@ async def test_service_say_en_gb_service(hass: HomeAssistant, mock_tts, calls) - } -async def test_service_say_fa_ir_config(hass: HomeAssistant, mock_tts, calls) -> None: +async def test_service_say_fa_ir_config( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts, calls +) -> None: """Test service call say with fa-ir code in the config.""" await async_setup_component( @@ -199,7 +212,11 @@ async def test_service_say_fa_ir_config(hass: HomeAssistant, mock_tts, calls) -> ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + assert len(mock_tts.mock_calls) == 2 assert mock_tts.mock_calls[1][2] == { "language": "fa-ir", @@ -214,7 +231,9 @@ async def test_service_say_fa_ir_config(hass: HomeAssistant, mock_tts, calls) -> } -async def test_service_say_fa_ir_service(hass: HomeAssistant, mock_tts, calls) -> None: +async def test_service_say_fa_ir_service( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts, calls +) -> None: """Test service call say with fa-ir code in the service.""" config = { @@ -240,7 +259,11 @@ async def test_service_say_fa_ir_service(hass: HomeAssistant, mock_tts, calls) - ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + assert len(mock_tts.mock_calls) == 2 assert mock_tts.mock_calls[1][2] == { "language": "fa-ir", @@ -295,7 +318,9 @@ async def test_invalid_language(hass: HomeAssistant, mock_tts, calls) -> None: assert len(mock_tts.mock_calls) == 0 -async def test_service_say_error(hass: HomeAssistant, mock_tts, calls) -> None: +async def test_service_say_error( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts, calls +) -> None: """Test service call say with http error.""" mock_tts.return_value.speak.side_effect = pycsspeechtts.requests.HTTPError await async_setup_component( @@ -313,6 +338,9 @@ async def test_service_say_error(hass: HomeAssistant, mock_tts, calls) -> None: ) assert len(calls) == 1 - with pytest.raises(HomeAssistantError): - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.NOT_FOUND + ) + assert len(mock_tts.mock_calls) == 2 diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py index 84fcfabffee..55cebaec525 100644 --- a/tests/components/mikrotik/test_device_tracker.py +++ b/tests/components/mikrotik/test_device_tracker.py @@ -208,29 +208,30 @@ async def test_hub_wifiwave2(hass: HomeAssistant, mock_device_registry_devices) assert device_4.attributes["host_name"] == "Device_4" -async def test_restoring_devices(hass: HomeAssistant) -> None: +async def test_restoring_devices( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test restoring existing device_tracker entities if not detected on startup.""" config_entry = MockConfigEntry( domain=mikrotik.DOMAIN, data=MOCK_DATA, options=MOCK_OPTIONS ) config_entry.add_to_hass(hass) - registry = er.async_get(hass) - registry.async_get_or_create( + entity_registry.async_get_or_create( device_tracker.DOMAIN, mikrotik.DOMAIN, "00:00:00:00:00:01", suggested_object_id="device_1", config_entry=config_entry, ) - registry.async_get_or_create( + entity_registry.async_get_or_create( device_tracker.DOMAIN, mikrotik.DOMAIN, "00:00:00:00:00:02", suggested_object_id="device_2", config_entry=config_entry, ) - registry.async_get_or_create( + entity_registry.async_get_or_create( device_tracker.DOMAIN, mikrotik.DOMAIN, "00:00:00:00:00:03", diff --git a/tests/components/mill/test_init.py b/tests/components/mill/test_init.py index 694e9537a8c..15175dedada 100644 --- a/tests/components/mill/test_init.py +++ b/tests/components/mill/test_init.py @@ -115,7 +115,8 @@ async def test_unload_entry(hass: HomeAssistant) -> None: ) as unload_entry, patch( "mill.Mill.fetch_heater_and_sensor_data", return_value={} ), patch( - "mill.Mill.connect", return_value=True + "mill.Mill.connect", + return_value=True, ): assert await async_setup_component(hass, "mill", {}) diff --git a/tests/components/min_max/test_init.py b/tests/components/min_max/test_init.py index 8d8eac5c700..cd07f7060f6 100644 --- a/tests/components/min_max/test_init.py +++ b/tests/components/min_max/test_init.py @@ -11,6 +11,7 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize("platform", ("sensor",)) async def test_setup_and_remove_config_entry( hass: HomeAssistant, + entity_registry: er.EntityRegistry, platform: str, ) -> None: """Test setting up and removing a config entry.""" @@ -19,7 +20,6 @@ async def test_setup_and_remove_config_entry( input_sensors = ["sensor.input_one", "sensor.input_two"] - registry = er.async_get(hass) min_max_entity_id = f"{platform}.my_min_max" # Setup the config entry @@ -39,7 +39,7 @@ async def test_setup_and_remove_config_entry( await hass.async_block_till_done() # Check the entity is registered in the entity registry - assert registry.async_get(min_max_entity_id) is not None + assert entity_registry.async_get(min_max_entity_id) is not None # Check the platform is setup correctly state = hass.states.get(min_max_entity_id) @@ -51,4 +51,4 @@ async def test_setup_and_remove_config_entry( # Check the state and entity registry entry are removed assert hass.states.get(min_max_entity_id) is None - assert registry.async_get(min_max_entity_id) is None + assert entity_registry.async_get(min_max_entity_id) is None diff --git a/tests/components/min_max/test_sensor.py b/tests/components/min_max/test_sensor.py index a742260daff..acd42f9355e 100644 --- a/tests/components/min_max/test_sensor.py +++ b/tests/components/min_max/test_sensor.py @@ -60,7 +60,9 @@ async def test_default_name_sensor(hass: HomeAssistant) -> None: assert entity_ids[2] == state.attributes.get("min_entity_id") -async def test_min_sensor(hass: HomeAssistant) -> None: +async def test_min_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the min sensor.""" config = { "sensor": { @@ -87,8 +89,7 @@ async def test_min_sensor(hass: HomeAssistant) -> None: assert entity_ids[2] == state.attributes.get("min_entity_id") assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entity_reg = er.async_get(hass) - entity = entity_reg.async_get("sensor.test_min") + entity = entity_registry.async_get("sensor.test_min") assert entity.unique_id == "very_unique_id" @@ -470,7 +471,9 @@ async def test_sensor_incorrect_state( assert "Unable to store state. Only numerical states are supported" in caplog.text -async def test_sum_sensor(hass: HomeAssistant) -> None: +async def test_sum_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the sum sensor.""" config = { "sensor": { @@ -496,8 +499,7 @@ async def test_sum_sensor(hass: HomeAssistant) -> None: assert str(float(SUM_VALUE)) == state.state assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entity_reg = er.async_get(hass) - entity = entity_reg.async_get("sensor.test_sum") + entity = entity_registry.async_get("sensor.test_sum") assert entity.unique_id == "very_unique_id_sum_sensor" diff --git a/tests/components/minecraft_server/test_init.py b/tests/components/minecraft_server/test_init.py index 09e411f0b62..018fdac542e 100644 --- a/tests/components/minecraft_server/test_init.py +++ b/tests/components/minecraft_server/test_init.py @@ -178,7 +178,10 @@ async def test_setup_entry_not_ready( async def test_entry_migration( - hass: HomeAssistant, v1_mock_config_entry: MockConfigEntry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + v1_mock_config_entry: MockConfigEntry, ) -> None: """Test entry migration from version 1 to 3, where host and port is required for the connection to the server.""" v1_mock_config_entry.add_to_hass(hass) @@ -218,12 +221,10 @@ async def test_entry_migration( assert migrated_config_entry.state == ConfigEntryState.LOADED # Test migrated device entry. - device_registry = dr.async_get(hass) device_entry = device_registry.async_get(device_entry_id) assert device_entry.identifiers == {(DOMAIN, migrated_config_entry.entry_id)} # Test migrated sensor entity entries. - entity_registry = er.async_get(hass) for mapping in sensor_entity_id_key_mapping_list: entity_entry = entity_registry.async_get(mapping["entity_id"]) assert ( diff --git a/tests/components/mobile_app/test_binary_sensor.py b/tests/components/mobile_app/test_binary_sensor.py index b8a6cbb6db6..fe3510865fc 100644 --- a/tests/components/mobile_app/test_binary_sensor.py +++ b/tests/components/mobile_app/test_binary_sensor.py @@ -9,7 +9,10 @@ from homeassistant.helpers import device_registry as dr async def test_sensor( - hass: HomeAssistant, create_registrations, webhook_client + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + create_registrations, + webhook_client, ) -> None: """Test that sensors can be registered and updated.""" webhook_id = create_registrations[1]["webhook_id"] @@ -77,8 +80,7 @@ async def test_sensor( assert updated_entity.state == "off" assert "foo" not in updated_entity.attributes - dev_reg = dr.async_get(hass) - assert len(dev_reg.devices) == len(create_registrations) + assert len(device_registry.devices) == len(create_registrations) # Reload to verify state is restored config_entry = hass.config_entries.async_entries("mobile_app")[1] diff --git a/tests/components/mobile_app/test_init.py b/tests/components/mobile_app/test_init.py index 8b034fb4ba9..59f2a130737 100644 --- a/tests/components/mobile_app/test_init.py +++ b/tests/components/mobile_app/test_init.py @@ -28,14 +28,16 @@ async def test_unload_unloads( assert len(calls) == 1 -async def test_remove_entry(hass: HomeAssistant, create_registrations) -> None: +async def test_remove_entry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + create_registrations, +) -> None: """Test we clean up when we remove entry.""" for config_entry in hass.config_entries.async_entries("mobile_app"): await hass.config_entries.async_remove(config_entry.entry_id) assert config_entry.data["webhook_id"] in hass.data[DOMAIN][DATA_DELETED_IDS] - dev_reg = dr.async_get(hass) - assert len(dev_reg.devices) == 0 - - ent_reg = er.async_get(hass) - assert len(ent_reg.entities) == 0 + assert len(device_registry.devices) == 0 + assert len(entity_registry.entities) == 0 diff --git a/tests/components/mobile_app/test_sensor.py b/tests/components/mobile_app/test_sensor.py index 8c8bf45fde2..f7c4a5690db 100644 --- a/tests/components/mobile_app/test_sensor.py +++ b/tests/components/mobile_app/test_sensor.py @@ -25,6 +25,8 @@ from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM ) async def test_sensor( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, create_registrations, webhook_client, unit_system, @@ -77,9 +79,7 @@ async def test_sensor( assert entity.state == state1 assert ( - er.async_get(hass) - .async_get("sensor.test_1_battery_temperature") - .entity_category + entity_registry.async_get("sensor.test_1_battery_temperature").entity_category == "diagnostic" ) @@ -109,8 +109,7 @@ async def test_sensor( assert updated_entity.state == state2 assert "foo" not in updated_entity.attributes - dev_reg = dr.async_get(hass) - assert len(dev_reg.devices) == len(create_registrations) + assert len(device_registry.devices) == len(create_registrations) # Reload to verify state is restored config_entry = hass.config_entries.async_entries("mobile_app")[1] @@ -503,7 +502,10 @@ async def test_sensor_datetime( async def test_default_disabling_entity( - hass: HomeAssistant, create_registrations, webhook_client + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + create_registrations, + webhook_client, ) -> None: """Test that sensors can be disabled by default upon registration.""" webhook_id = create_registrations[1]["webhook_id"] @@ -532,13 +534,16 @@ async def test_default_disabling_entity( assert entity is None assert ( - er.async_get(hass).async_get("sensor.test_1_battery_state").disabled_by + entity_registry.async_get("sensor.test_1_battery_state").disabled_by == er.RegistryEntryDisabler.INTEGRATION ) async def test_updating_disabled_sensor( - hass: HomeAssistant, create_registrations, webhook_client + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + create_registrations, + webhook_client, ) -> None: """Test that sensors return error if disabled in instance.""" webhook_id = create_registrations[1]["webhook_id"] @@ -580,7 +585,7 @@ async def test_updating_disabled_sensor( assert json["battery_state"]["success"] is True assert "is_disabled" not in json["battery_state"] - er.async_get(hass).async_update_entity( + entity_registry.async_update_entity( "sensor.test_1_battery_state", disabled_by=er.RegistryEntryDisabler.USER ) diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 9f6aec404e2..6fe272fbc40 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -854,12 +854,13 @@ async def test_webhook_camera_stream_stream_available_but_errors( async def test_webhook_handle_scan_tag( - hass: HomeAssistant, create_registrations, webhook_client + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + create_registrations, + webhook_client, ) -> None: """Test that we can scan tags.""" - device = dr.async_get(hass).async_get_device( - identifiers={(DOMAIN, "mock-device-id")} - ) + device = device_registry.async_get_device(identifiers={(DOMAIN, "mock-device-id")}) assert device is not None events = async_capture_events(hass, EVENT_TAG_SCANNED) @@ -920,7 +921,10 @@ async def test_register_sensor_limits_state_class( async def test_reregister_sensor( - hass: HomeAssistant, create_registrations, webhook_client + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + create_registrations, + webhook_client, ) -> None: """Test that we can add more info in re-registration.""" webhook_id = create_registrations[1]["webhook_id"] @@ -941,8 +945,7 @@ async def test_reregister_sensor( assert reg_resp.status == HTTPStatus.CREATED - ent_reg = er.async_get(hass) - entry = ent_reg.async_get("sensor.test_1_battery_state") + entry = entity_registry.async_get("sensor.test_1_battery_state") assert entry.original_name == "Test 1 Battery State" assert entry.device_class is None assert entry.unit_of_measurement is None @@ -970,7 +973,7 @@ async def test_reregister_sensor( ) assert reg_resp.status == HTTPStatus.CREATED - entry = ent_reg.async_get("sensor.test_1_battery_state") + entry = entity_registry.async_get("sensor.test_1_battery_state") assert entry.original_name == "Test 1 New Name" assert entry.device_class == "battery" assert entry.unit_of_measurement == "%" @@ -992,7 +995,7 @@ async def test_reregister_sensor( ) assert reg_resp.status == HTTPStatus.CREATED - entry = ent_reg.async_get("sensor.test_1_battery_state") + entry = entity_registry.async_get("sensor.test_1_battery_state") assert entry.disabled_by is None reg_resp = await webhook_client.post( @@ -1014,7 +1017,7 @@ async def test_reregister_sensor( ) assert reg_resp.status == HTTPStatus.CREATED - entry = ent_reg.async_get("sensor.test_1_battery_state") + entry = entity_registry.async_get("sensor.test_1_battery_state") assert entry.original_name == "Test 1 New Name 2" assert entry.device_class is None assert entry.unit_of_measurement is None @@ -1067,6 +1070,7 @@ async def test_webhook_handle_conversation_process( async def test_sending_sensor_state( hass: HomeAssistant, + entity_registry: er.EntityRegistry, create_registrations, webhook_client, caplog: pytest.LogCaptureFixture, @@ -1105,8 +1109,7 @@ async def test_sending_sensor_state( assert reg_resp.status == HTTPStatus.CREATED - ent_reg = er.async_get(hass) - entry = ent_reg.async_get("sensor.test_1_battery_state") + entry = entity_registry.async_get("sensor.test_1_battery_state") assert entry.original_name == "Test 1 Battery State" assert entry.device_class is None assert entry.unit_of_measurement is None diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index 2069aa23b8f..a892dd205fb 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -445,11 +445,14 @@ async def test_config_virtual_binary_sensor(hass: HomeAssistant, mock_modbus) -> ], ) async def test_virtual_binary_sensor( - hass: HomeAssistant, expected, slaves, mock_do_cycle + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + expected, + slaves, + mock_do_cycle, ) -> None: """Run test for given config.""" assert hass.states.get(ENTITY_ID).state == expected - entity_registry = er.async_get(hass) for i, slave in enumerate(slaves): entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}_{i+1}".replace(" ", "_") diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 72aebbd396f..d0a4e23f780 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -247,7 +247,7 @@ async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: }, ] }, - f"{TEST_ENTITY_NAME}: `{CONF_STRUCTURE}` missing or empty, demanded with `{CONF_DATA_TYPE}: {DataType.CUSTOM}`", + f"{TEST_ENTITY_NAME}: Size of structure is 0 bytes but `{CONF_COUNT}: 4` is 8 bytes", ), ( { @@ -276,7 +276,7 @@ async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: }, ] }, - f"{TEST_ENTITY_NAME}: `{CONF_SWAP}:{CONF_SWAP_WORD}` cannot be combined with `{CONF_DATA_TYPE}: {DataType.CUSTOM}`", + f"{TEST_ENTITY_NAME}: `{CONF_SWAP}:{CONF_SWAP_WORD}` illegal with `{CONF_DATA_TYPE}: {DataType.CUSTOM}`", ), ], ) @@ -869,9 +869,10 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: ), ], ) -async def test_virtual_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: +async def test_virtual_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_do_cycle, expected +) -> None: """Run test for sensor.""" - entity_registry = er.async_get(hass) for i in range(0, len(expected)): entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") unique_id = f"{SLAVE_UNIQUE_ID}" diff --git a/tests/components/modern_forms/test_binary_sensor.py b/tests/components/modern_forms/test_binary_sensor.py index 6b64beb4f1a..3ea0fca99d5 100644 --- a/tests/components/modern_forms/test_binary_sensor.py +++ b/tests/components/modern_forms/test_binary_sensor.py @@ -11,20 +11,20 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_binary_sensors( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test the creation and values of the Modern Forms sensors.""" - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( BINARY_SENSOR_DOMAIN, DOMAIN, "AA:BB:CC:DD:EE:FF_light_sleep_timer_active", suggested_object_id="modernformsfan_light_sleep_timer_active", disabled_by=None, ) - registry.async_get_or_create( + entity_registry.async_get_or_create( BINARY_SENSOR_DOMAIN, DOMAIN, "AA:BB:CC:DD:EE:FF_fan_sleep_timer_active", diff --git a/tests/components/modern_forms/test_fan.py b/tests/components/modern_forms/test_fan.py index 12083bb5ab6..9dc5ca9960f 100644 --- a/tests/components/modern_forms/test_fan.py +++ b/tests/components/modern_forms/test_fan.py @@ -35,13 +35,13 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_fan_state( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test the creation and values of the Modern Forms fans.""" await init_integration(hass, aioclient_mock) - entity_registry = er.async_get(hass) - state = hass.states.get("fan.modernformsfan_fan") assert state assert state.attributes.get(ATTR_PERCENTAGE) == 50 diff --git a/tests/components/modern_forms/test_init.py b/tests/components/modern_forms/test_init.py index b989f0f9ef3..9befb36d00d 100644 --- a/tests/components/modern_forms/test_init.py +++ b/tests/components/modern_forms/test_init.py @@ -38,13 +38,14 @@ async def test_unload_config_entry( async def test_fan_only_device( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test we set unique ID if not set yet.""" await init_integration( hass, aioclient_mock, mock_type=modern_forms_no_light_call_mock ) - entity_registry = er.async_get(hass) fan_entry = entity_registry.async_get("fan.modernformsfan_fan") assert fan_entry diff --git a/tests/components/modern_forms/test_light.py b/tests/components/modern_forms/test_light.py index 7e5b5e824f2..080290944b2 100644 --- a/tests/components/modern_forms/test_light.py +++ b/tests/components/modern_forms/test_light.py @@ -28,13 +28,13 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_light_state( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test the creation and values of the Modern Forms lights.""" await init_integration(hass, aioclient_mock) - entity_registry = er.async_get(hass) - state = hass.states.get("light.modernformsfan_light") assert state assert state.attributes.get(ATTR_BRIGHTNESS) == 128 diff --git a/tests/components/modern_forms/test_sensor.py b/tests/components/modern_forms/test_sensor.py index 7e3914cd7d9..279942f39a9 100644 --- a/tests/components/modern_forms/test_sensor.py +++ b/tests/components/modern_forms/test_sensor.py @@ -4,7 +4,6 @@ from datetime import datetime from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ICON from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from . import init_integration, modern_forms_timers_set_mock @@ -18,7 +17,6 @@ async def test_sensors( # await init_integration(hass, aioclient_mock) await init_integration(hass, aioclient_mock) - er.async_get(hass) # Light timer remaining time state = hass.states.get("sensor.modernformsfan_light_sleep_time") @@ -42,7 +40,6 @@ async def test_active_sensors( # await init_integration(hass, aioclient_mock) await init_integration(hass, aioclient_mock, mock_type=modern_forms_timers_set_mock) - er.async_get(hass) # Light timer remaining time state = hass.states.get("sensor.modernformsfan_light_sleep_time") diff --git a/tests/components/modern_forms/test_switch.py b/tests/components/modern_forms/test_switch.py index eae51d034f6..b0ddc31150b 100644 --- a/tests/components/modern_forms/test_switch.py +++ b/tests/components/modern_forms/test_switch.py @@ -22,13 +22,13 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_switch_state( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test the creation and values of the Modern Forms switches.""" await init_integration(hass, aioclient_mock) - entity_registry = er.async_get(hass) - state = hass.states.get("switch.modernformsfan_away_mode") assert state assert state.attributes.get(ATTR_ICON) == "mdi:airplane-takeoff" diff --git a/tests/components/monoprice/test_media_player.py b/tests/components/monoprice/test_media_player.py index fb1c2ece186..c2f9ef01111 100644 --- a/tests/components/monoprice/test_media_player.py +++ b/tests/components/monoprice/test_media_player.py @@ -489,45 +489,45 @@ async def test_volume_up_down(hass: HomeAssistant) -> None: assert monoprice.zones[11].volume == 37 -async def test_first_run_with_available_zones(hass: HomeAssistant) -> None: +async def test_first_run_with_available_zones( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test first run with all zones available.""" monoprice = MockMonoprice() await _setup_monoprice(hass, monoprice) - registry = er.async_get(hass) - - entry = registry.async_get(ZONE_7_ID) + entry = entity_registry.async_get(ZONE_7_ID) assert not entry.disabled -async def test_first_run_with_failing_zones(hass: HomeAssistant) -> None: +async def test_first_run_with_failing_zones( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test first run with failed zones.""" monoprice = MockMonoprice() with patch.object(MockMonoprice, "zone_status", side_effect=SerialException): await _setup_monoprice(hass, monoprice) - registry = er.async_get(hass) - - entry = registry.async_get(ZONE_1_ID) + entry = entity_registry.async_get(ZONE_1_ID) assert not entry.disabled - entry = registry.async_get(ZONE_7_ID) + entry = entity_registry.async_get(ZONE_7_ID) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION -async def test_not_first_run_with_failing_zone(hass: HomeAssistant) -> None: +async def test_not_first_run_with_failing_zone( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test first run with failed zones.""" monoprice = MockMonoprice() with patch.object(MockMonoprice, "zone_status", side_effect=SerialException): await _setup_monoprice_not_first_run(hass, monoprice) - registry = er.async_get(hass) - - entry = registry.async_get(ZONE_1_ID) + entry = entity_registry.async_get(ZONE_1_ID) assert not entry.disabled - entry = registry.async_get(ZONE_7_ID) + entry = entity_registry.async_get(ZONE_7_ID) assert not entry.disabled diff --git a/tests/components/moon/test_sensor.py b/tests/components/moon/test_sensor.py index 922febed3bf..38af8dcb912 100644 --- a/tests/components/moon/test_sensor.py +++ b/tests/components/moon/test_sensor.py @@ -39,6 +39,8 @@ from tests.common import MockConfigEntry ) async def test_moon_day( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, moon_value: float, native_value: str, @@ -70,13 +72,11 @@ async def test_moon_day( STATE_WANING_CRESCENT, ] - entity_registry = er.async_get(hass) entry = entity_registry.async_get("sensor.moon_phase") assert entry assert entry.unique_id == mock_config_entry.entry_id assert entry.translation_key == "phase" - device_registry = dr.async_get(hass) assert entry.device_id device_entry = device_registry.async_get(entry.device_id) assert device_entry diff --git a/tests/components/motioneye/test_camera.py b/tests/components/motioneye/test_camera.py index 5f5c5f7854e..5af8d4139eb 100644 --- a/tests/components/motioneye/test_camera.py +++ b/tests/components/motioneye/test_camera.py @@ -135,10 +135,12 @@ async def test_setup_camera_new_data_same(hass: HomeAssistant) -> None: assert hass.states.get(TEST_CAMERA_ENTITY_ID) -async def test_setup_camera_new_data_camera_removed(hass: HomeAssistant) -> None: +async def test_setup_camera_new_data_camera_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test a data refresh with a removed camera.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) client = create_mock_motioneye_client() config_entry = await setup_mock_motioneye_config_entry(hass, client=client) @@ -315,12 +317,15 @@ async def test_state_attributes(hass: HomeAssistant) -> None: assert not entity_state.attributes.get("motion_detection") -async def test_device_info(hass: HomeAssistant) -> None: +async def test_device_info( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Verify device information includes expected details.""" entry = await setup_mock_motioneye_config_entry(hass) device_identifier = get_motioneye_device_identifier(entry.entry_id, TEST_CAMERA_ID) - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={device_identifier}) assert device @@ -330,7 +335,6 @@ async def test_device_info(hass: HomeAssistant) -> None: assert device.model == MOTIONEYE_MANUFACTURER assert device.name == TEST_CAMERA_NAME - entity_registry = er.async_get(hass) entities_from_device = [ entry.entity_id for entry in er.async_entries_for_device(entity_registry, device.id) diff --git a/tests/components/motioneye/test_media_source.py b/tests/components/motioneye/test_media_source.py index cb42e51f474..6b90870c4da 100644 --- a/tests/components/motioneye/test_media_source.py +++ b/tests/components/motioneye/test_media_source.py @@ -78,13 +78,14 @@ async def setup_media_source(hass) -> None: assert await async_setup_component(hass, "media_source", {}) -async def test_async_browse_media_success(hass: HomeAssistant) -> None: +async def test_async_browse_media_success( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test successful browse media.""" client = create_mock_motioneye_client() config = await setup_mock_motioneye_config_entry(hass, client=client) - device_registry = dr.async_get(hass) device = device_registry.async_get_or_create( config_entry_id=config.entry_id, identifiers={TEST_CAMERA_DEVICE_IDENTIFIER}, @@ -295,13 +296,14 @@ async def test_async_browse_media_success(hass: HomeAssistant) -> None: } -async def test_async_browse_media_images_success(hass: HomeAssistant) -> None: +async def test_async_browse_media_images_success( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test successful browse media of images.""" client = create_mock_motioneye_client() config = await setup_mock_motioneye_config_entry(hass, client=client) - device_registry = dr.async_get(hass) device = device_registry.async_get_or_create( config_entry_id=config.entry_id, identifiers={TEST_CAMERA_DEVICE_IDENTIFIER}, @@ -346,14 +348,15 @@ async def test_async_browse_media_images_success(hass: HomeAssistant) -> None: } -async def test_async_resolve_media_success(hass: HomeAssistant) -> None: +async def test_async_resolve_media_success( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test successful resolve media.""" client = create_mock_motioneye_client() config = await setup_mock_motioneye_config_entry(hass, client=client) - device_registry = dr.async_get(hass) device = device_registry.async_get_or_create( config_entry_id=config.entry_id, identifiers={TEST_CAMERA_DEVICE_IDENTIFIER}, @@ -380,14 +383,15 @@ async def test_async_resolve_media_success(hass: HomeAssistant) -> None: assert client.get_image_url.call_args == call(TEST_CAMERA_ID, "/foo.jpg") -async def test_async_resolve_media_failure(hass: HomeAssistant) -> None: +async def test_async_resolve_media_failure( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test failed resolve media calls.""" client = create_mock_motioneye_client() config = await setup_mock_motioneye_config_entry(hass, client=client) - device_registry = dr.async_get(hass) device = device_registry.async_get_or_create( config_entry_id=config.entry_id, identifiers={TEST_CAMERA_DEVICE_IDENTIFIER}, diff --git a/tests/components/motioneye/test_sensor.py b/tests/components/motioneye/test_sensor.py index 659738ef2c5..0892c0dead0 100644 --- a/tests/components/motioneye/test_sensor.py +++ b/tests/components/motioneye/test_sensor.py @@ -73,7 +73,11 @@ async def test_sensor_actions( assert entity_state.attributes.get(KEY_ACTIONS) is None -async def test_sensor_device_info(hass: HomeAssistant) -> None: +async def test_sensor_device_info( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Verify device information includes expected details.""" # Enable the action sensor (it is disabled by default). @@ -91,11 +95,9 @@ async def test_sensor_device_info(hass: HomeAssistant) -> None: config_entry.entry_id, TEST_CAMERA_ID ) - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={device_identifer}) assert device - entity_registry = er.async_get(hass) entities_from_device = [ entry.entity_id for entry in er.async_entries_for_device(entity_registry, device.id) @@ -104,12 +106,13 @@ async def test_sensor_device_info(hass: HomeAssistant) -> None: async def test_sensor_actions_can_be_enabled( - hass: HomeAssistant, freezer: FrozenDateTimeFactory + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, ) -> None: """Verify the action sensor can be enabled.""" client = create_mock_motioneye_client() await setup_mock_motioneye_config_entry(hass, client=client) - entity_registry = er.async_get(hass) entry = entity_registry.async_get(TEST_SENSOR_ACTION_ENTITY_ID) assert entry diff --git a/tests/components/motioneye/test_switch.py b/tests/components/motioneye/test_switch.py index cc193f5fb60..a6fbcc49052 100644 --- a/tests/components/motioneye/test_switch.py +++ b/tests/components/motioneye/test_switch.py @@ -152,7 +152,9 @@ async def test_switch_has_correct_entities(hass: HomeAssistant) -> None: async def test_disabled_switches_can_be_enabled( - hass: HomeAssistant, freezer: FrozenDateTimeFactory + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, ) -> None: """Verify disabled switches can be enabled.""" client = create_mock_motioneye_client() @@ -165,7 +167,6 @@ async def test_disabled_switches_can_be_enabled( for switch_key in disabled_switch_keys: entity_id = f"{TEST_SWITCH_ENTITY_ID_BASE}_{switch_key}" - entity_registry = er.async_get(hass) entry = entity_registry.async_get(entity_id) assert entry assert entry.disabled @@ -191,19 +192,21 @@ async def test_disabled_switches_can_be_enabled( assert entity_state -async def test_switch_device_info(hass: HomeAssistant) -> None: +async def test_switch_device_info( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Verify device information includes expected details.""" config_entry = await setup_mock_motioneye_config_entry(hass) device_identifer = get_motioneye_device_identifier( config_entry.entry_id, TEST_CAMERA_ID ) - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={device_identifer}) assert device - entity_registry = er.async_get(hass) entities_from_device = [ entry.entity_id for entry in er.async_entries_for_device(entity_registry, device.id) diff --git a/tests/components/motioneye/test_web_hooks.py b/tests/components/motioneye/test_web_hooks.py index 617f472ab4e..7c66645bb44 100644 --- a/tests/components/motioneye/test_web_hooks.py +++ b/tests/components/motioneye/test_web_hooks.py @@ -63,12 +63,13 @@ WEB_HOOK_FILE_STORED_QUERY_STRING = ( ) -async def test_setup_camera_without_webhook(hass: HomeAssistant) -> None: +async def test_setup_camera_without_webhook( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test a camera with no webhook.""" client = create_mock_motioneye_client() config_entry = await setup_mock_motioneye_config_entry(hass, client=client) - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={TEST_CAMERA_DEVICE_IDENTIFIER} ) @@ -95,6 +96,7 @@ async def test_setup_camera_without_webhook(hass: HomeAssistant) -> None: async def test_setup_camera_with_wrong_webhook( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, ) -> None: """Test camera with wrong web hook.""" wrong_url = "http://wrong-url" @@ -123,7 +125,6 @@ async def test_setup_camera_with_wrong_webhook( ) await hass.async_block_till_done() - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={TEST_CAMERA_DEVICE_IDENTIFIER} ) @@ -151,6 +152,7 @@ async def test_setup_camera_with_wrong_webhook( async def test_setup_camera_with_old_webhook( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, ) -> None: """Verify that webhooks are overwritten if they are from this integration. @@ -176,7 +178,6 @@ async def test_setup_camera_with_old_webhook( ) assert client.async_set_camera.called - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={TEST_CAMERA_DEVICE_IDENTIFIER} ) @@ -204,6 +205,7 @@ async def test_setup_camera_with_old_webhook( async def test_setup_camera_with_correct_webhook( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, ) -> None: """Verify that webhooks are not overwritten if they are already correct.""" @@ -212,7 +214,6 @@ async def test_setup_camera_with_correct_webhook( hass, data={CONF_URL: TEST_URL, CONF_WEBHOOK_ID: "webhook_secret_id"} ) - device_registry = dr.async_get(hass) device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={TEST_CAMERA_DEVICE_IDENTIFIER}, @@ -278,12 +279,13 @@ async def test_setup_camera_with_no_home_assistant_urls( async def test_good_query( - hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test good callbacks.""" await async_setup_component(hass, "http", {"http": {}}) - device_registry = dr.async_get(hass) client = create_mock_motioneye_client() config_entry = await setup_mock_motioneye_config_entry(hass, client=client) @@ -377,12 +379,13 @@ async def test_bad_query_cannot_decode( async def test_event_media_data( - hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test an event with a file path generates media data.""" await async_setup_component(hass, "http", {"http": {}}) - device_registry = dr.async_get(hass) client = create_mock_motioneye_client() config_entry = await setup_mock_motioneye_config_entry(hass, client=client) diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index f3bf92951b0..8db1c89bc40 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -3347,6 +3347,11 @@ async def test_set_state_via_stopped_state_no_position_topic( state = hass.states.get("cover.test") assert state.state == STATE_CLOSED + async_fire_mqtt_message(hass, "state-topic", "STOPPED") + + state = hass.states.get("cover.test") + assert state.state == STATE_CLOSED + @pytest.mark.parametrize( "hass_config", diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index 485c2774f7b..90360bf7e3f 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -973,11 +973,12 @@ async def test_attach_remove_late2( async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test MQTT device registry integration.""" await mqtt_mock_entry() - registry = dr.async_get(hass) data = json.dumps( { @@ -998,7 +999,7 @@ async def test_entity_device_info_with_connection( async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device( + device = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, "02:5b:26:a8:dc:12")} ) assert device is not None @@ -1011,11 +1012,12 @@ async def test_entity_device_info_with_connection( async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test MQTT device registry integration.""" await mqtt_mock_entry() - registry = dr.async_get(hass) data = json.dumps( { @@ -1036,7 +1038,7 @@ async def test_entity_device_info_with_identifier( async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.identifiers == {("mqtt", "helloworld")} assert device.manufacturer == "Whatever" @@ -1047,11 +1049,12 @@ async def test_entity_device_info_with_identifier( async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test device registry update.""" await mqtt_mock_entry() - registry = dr.async_get(hass) config = { "automation_type": "trigger", @@ -1072,7 +1075,7 @@ async def test_entity_device_info_update( async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.name == "Beer" @@ -1081,7 +1084,7 @@ async def test_entity_device_info_update( async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.name == "Milk" @@ -1390,14 +1393,15 @@ async def test_cleanup_device_with_entity2( async def test_trigger_debug_info( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test debug_info. This is a test helper for MQTT debug_info. """ await mqtt_mock_entry() - registry = dr.async_get(hass) config1 = { "platform": "mqtt", @@ -1429,7 +1433,7 @@ async def test_trigger_debug_info( async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", data) await hass.async_block_till_done() - device = registry.async_get_device( + device = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, "02:5b:26:a8:dc:12")} ) assert device is not None diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 863a79fce70..017d24a39ce 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -34,7 +34,8 @@ from tests.common import ( MockConfigEntry, async_capture_events, async_fire_mqtt_message, - mock_entity_platform, + mock_config_flow, + mock_platform, ) from tests.typing import ( MqttMockHAClientGenerator, @@ -1499,7 +1500,7 @@ async def test_mqtt_integration_discovery_subscribe_unsubscribe( ) -> None: """Check MQTT integration discovery subscribe and unsubscribe.""" mqtt_mock = await mqtt_mock_entry() - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) entry = hass.config_entries.async_entries("mqtt")[0] mqtt_mock().connected = True @@ -1522,19 +1523,21 @@ async def test_mqtt_integration_discovery_subscribe_unsubscribe( """Test mqtt step.""" return self.async_abort(reason="already_configured") - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): - await asyncio.sleep(0.1) + with mock_config_flow("comp", TestFlow): + await asyncio.sleep(0) assert ("comp/discovery/#", 0) in help_all_subscribe_calls(mqtt_client_mock) assert not mqtt_client_mock.unsubscribe.called async_fire_mqtt_message(hass, "comp/discovery/bla/config", "") - await asyncio.sleep(0.1) + await asyncio.sleep(0) + await hass.async_block_till_done() await hass.async_block_till_done() mqtt_client_mock.unsubscribe.assert_called_once_with(["comp/discovery/#"]) mqtt_client_mock.unsubscribe.reset_mock() async_fire_mqtt_message(hass, "comp/discovery/bla/config", "") - await asyncio.sleep(0.1) + await asyncio.sleep(0) + await hass.async_block_till_done() await hass.async_block_till_done() assert not mqtt_client_mock.unsubscribe.called @@ -1550,7 +1553,7 @@ async def test_mqtt_discovery_unsubscribe_once( ) -> None: """Check MQTT integration discovery unsubscribe once.""" mqtt_mock = await mqtt_mock_entry() - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) entry = hass.config_entries.async_entries("mqtt")[0] mqtt_mock().connected = True @@ -1573,7 +1576,7 @@ async def test_mqtt_discovery_unsubscribe_once( """Test mqtt step.""" return self.async_abort(reason="already_configured") - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + with mock_config_flow("comp", TestFlow): async_fire_mqtt_message(hass, "comp/discovery/bla/config", "") async_fire_mqtt_message(hass, "comp/discovery/bla/config", "") await asyncio.sleep(0.1) diff --git a/tests/components/mqtt/test_event.py b/tests/components/mqtt/test_event.py index 4c0e63fec1f..e178eb40c0e 100644 --- a/tests/components/mqtt/test_event.py +++ b/tests/components/mqtt/test_event.py @@ -500,14 +500,15 @@ async def test_entity_id_update_discovery_update( async def test_entity_device_info_with_hub( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test MQTT event device registry integration.""" await mqtt_mock_entry() other_config_entry = MockConfigEntry() other_config_entry.add_to_hass(hass) - registry = dr.async_get(hass) - hub = registry.async_get_or_create( + hub = device_registry.async_get_or_create( config_entry_id=other_config_entry.entry_id, connections=set(), identifiers={("mqtt", "hub-id")}, @@ -527,7 +528,7 @@ async def test_entity_device_info_with_hub( async_fire_mqtt_message(hass, "homeassistant/event/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.via_device_id == hub.id diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 21d3bcce3a9..e7c4eba54e2 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -705,8 +705,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "low") + assert exc.value.translation_key == "not_valid_preset_mode" await common.async_set_preset_mode(hass, "fan.test", "whoosh") mqtt_mock.async_publish.assert_called_once_with( @@ -916,11 +917,13 @@ async def test_sending_mqtt_commands_and_optimistic_no_legacy( assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "low") + assert exc.value.translation_key == "not_valid_preset_mode" - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "auto") + assert exc.value.translation_key == "not_valid_preset_mode" await common.async_set_preset_mode(hass, "fan.test", "whoosh") mqtt_mock.async_publish.assert_called_once_with( @@ -976,8 +979,9 @@ async def test_sending_mqtt_commands_and_optimistic_no_legacy( assert state.state == STATE_ON assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_turn_on(hass, "fan.test", preset_mode="freaking-high") + assert exc.value.translation_key == "not_valid_preset_mode" @pytest.mark.parametrize( @@ -1078,11 +1082,13 @@ async def test_sending_mqtt_command_templates_( assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "low") + assert exc.value.translation_key == "not_valid_preset_mode" - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "medium") + assert exc.value.translation_key == "not_valid_preset_mode" await common.async_set_preset_mode(hass, "fan.test", "whoosh") mqtt_mock.async_publish.assert_called_once_with( @@ -1140,8 +1146,9 @@ async def test_sending_mqtt_command_templates_( assert state.state == STATE_ON assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_turn_on(hass, "fan.test", preset_mode="low") + assert exc.value.translation_key == "not_valid_preset_mode" @pytest.mark.parametrize( @@ -1176,8 +1183,9 @@ async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( assert state.state == STATE_UNKNOWN assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "medium") + assert exc.value.translation_key == "not_valid_preset_mode" await common.async_set_preset_mode(hass, "fan.test", "whoosh") mqtt_mock.async_publish.assert_called_once_with( @@ -1276,11 +1284,10 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_turn_on(hass, "fan.test", preset_mode="auto") - assert mqtt_mock.async_publish.call_count == 1 - # We can turn on, but the invalid preset mode will raise - mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) + assert exc.value.translation_key == "not_valid_preset_mode" + assert mqtt_mock.async_publish.call_count == 0 mqtt_mock.async_publish.reset_mock() await common.async_turn_on(hass, "fan.test", preset_mode="whoosh") @@ -1428,11 +1435,13 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( with pytest.raises(MultipleInvalid): await common.async_set_percentage(hass, "fan.test", 101) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "low") + assert exc.value.translation_key == "not_valid_preset_mode" - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "medium") + assert exc.value.translation_key == "not_valid_preset_mode" await common.async_set_preset_mode(hass, "fan.test", "whoosh") mqtt_mock.async_publish.assert_called_once_with( @@ -1452,8 +1461,9 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "freaking-high") + assert exc.value.translation_key == "not_valid_preset_mode" mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 8112a289e62..d31570548f0 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -30,7 +30,12 @@ from homeassistant.const import ( import homeassistant.core as ha from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, entity_registry as er, template +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, + template, +) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.typing import ConfigType @@ -42,6 +47,7 @@ from .test_common import help_all_subscribe_calls from tests.common import ( MockConfigEntry, MockEntity, + async_capture_events, async_fire_mqtt_message, async_fire_time_changed, mock_restore_cache, @@ -2152,17 +2158,32 @@ async def test_setup_manual_mqtt_with_invalid_config( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "entity_id"), [ - { - mqtt.DOMAIN: { - "sensor": { - "name": "test", - "state_topic": "test-topic", - "entity_category": "config", + ( + { + mqtt.DOMAIN: { + "sensor": { + "name": "test", + "state_topic": "test-topic", + "entity_category": "config", + } } - } - }, + }, + "sensor.test", + ), + ( + { + mqtt.DOMAIN: { + "binary_sensor": { + "name": "test", + "state_topic": "test-topic", + "entity_category": "config", + } + } + }, + "binary_sensor.test", + ), ], ) @patch( @@ -2172,10 +2193,60 @@ async def test_setup_manual_mqtt_with_invalid_entity_category( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, + entity_id: str, ) -> None: """Test set up a manual sensor item with an invalid entity category.""" + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) assert await mqtt_mock_entry() - assert "Entity category `config` is invalid" in caplog.text + assert "Entity category `config` is invalid for sensors, ignoring" in caplog.text + state = hass.states.get(entity_id) + assert state is not None + assert len(events) == 1 + + +@pytest.mark.parametrize( + ("config", "entity_id"), + [ + ( + { + "name": "test", + "state_topic": "test-topic", + "entity_category": "config", + }, + "binary_sensor.test", + ), + ( + { + "name": "test", + "state_topic": "test-topic", + "entity_category": "config", + }, + "sensor.test", + ), + ], +) +@patch( + "homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR, Platform.SENSOR] +) +async def test_setup_discovery_mqtt_with_invalid_entity_category( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + config: dict[str, Any], + entity_id: str, +) -> None: + """Test set up a discovered sensor item with an invalid entity category.""" + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) + assert await mqtt_mock_entry() + + domain = entity_id.split(".")[0] + json_config = json.dumps(config) + async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", json_config) + await hass.async_block_till_done() + assert "Entity category `config` is invalid for sensors, ignoring" in caplog.text + state = hass.states.get(entity_id) + assert state is not None + assert len(events) == 0 @patch("homeassistant.components.mqtt.PLATFORMS", []) @@ -2979,7 +3050,9 @@ async def test_mqtt_ws_get_device_debug_info_binary( async def test_debug_info_multiple_devices( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test we get correct debug_info when multiple devices are present.""" await mqtt_mock_entry() @@ -3026,8 +3099,6 @@ async def test_debug_info_multiple_devices( }, ] - registry = dr.async_get(hass) - for dev in devices: data = json.dumps(dev["config"]) domain = dev["domain"] @@ -3038,7 +3109,7 @@ async def test_debug_info_multiple_devices( for dev in devices: domain = dev["domain"] id = dev["config"]["device"]["identifiers"][0] - device = registry.async_get_device(identifiers={("mqtt", id)}) + device = device_registry.async_get_device(identifiers={("mqtt", id)}) assert device is not None debug_info_data = debug_info.info_for_device(hass, device.id) @@ -3061,7 +3132,9 @@ async def test_debug_info_multiple_devices( async def test_debug_info_multiple_entities_triggers( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test we get correct debug_info for a device with multiple entities and triggers.""" await mqtt_mock_entry() @@ -3108,8 +3181,6 @@ async def test_debug_info_multiple_entities_triggers( }, ] - registry = dr.async_get(hass) - for c in config: data = json.dumps(c["config"]) domain = c["domain"] @@ -3119,7 +3190,7 @@ async def test_debug_info_multiple_entities_triggers( await hass.async_block_till_done() device_id = config[0]["config"]["device"]["identifiers"][0] - device = registry.async_get_device(identifiers={("mqtt", device_id)}) + device = device_registry.async_get_device(identifiers={("mqtt", device_id)}) assert device is not None debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"]) == 2 @@ -3182,7 +3253,9 @@ async def test_debug_info_non_mqtt( async def test_debug_info_wildcard( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test debug info.""" await mqtt_mock_entry() @@ -3193,13 +3266,11 @@ async def test_debug_info_wildcard( "unique_id": "veryunique", } - registry = dr.async_get(hass) - data = json.dumps(config) async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None debug_info_data = debug_info.info_for_device(hass, device.id) @@ -3230,7 +3301,9 @@ async def test_debug_info_wildcard( async def test_debug_info_filter_same( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test debug info removes messages with same timestamp.""" await mqtt_mock_entry() @@ -3241,13 +3314,11 @@ async def test_debug_info_filter_same( "unique_id": "veryunique", } - registry = dr.async_get(hass) - data = json.dumps(config) async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None debug_info_data = debug_info.info_for_device(hass, device.id) @@ -3290,7 +3361,9 @@ async def test_debug_info_filter_same( async def test_debug_info_same_topic( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test debug info.""" await mqtt_mock_entry() @@ -3302,13 +3375,11 @@ async def test_debug_info_same_topic( "unique_id": "veryunique", } - registry = dr.async_get(hass) - data = json.dumps(config) async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None debug_info_data = debug_info.info_for_device(hass, device.id) @@ -3344,7 +3415,9 @@ async def test_debug_info_same_topic( async def test_debug_info_qos_retain( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test debug info.""" await mqtt_mock_entry() @@ -3355,13 +3428,11 @@ async def test_debug_info_qos_retain( "unique_id": "veryunique", } - registry = dr.async_get(hass) - data = json.dumps(config) async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None debug_info_data = debug_info.info_for_device(hass, device.id) diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index b3dd3a9a4e3..82b0b3467f4 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -1792,7 +1792,7 @@ async def test_brightness_scale( state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("brightness") == 128 + assert state.attributes.get("brightness") == 129 # Test limmiting max brightness async_fire_mqtt_message( @@ -1862,7 +1862,7 @@ async def test_white_scale( state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("brightness") == 128 + assert state.attributes.get("brightness") == 129 @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index 1ca9bf07d72..7a625a2f5f6 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -312,6 +312,7 @@ async def test_availability_with_shared_state_topic( @patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) async def test_default_entity_and_device_name( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data, caplog: pytest.LogCaptureFixture, @@ -336,9 +337,7 @@ async def test_default_entity_and_device_name( hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - registry = dr.async_get(hass) - - device = registry.async_get_device({("mqtt", "helloworld")}) + device = device_registry.async_get_device({("mqtt", "helloworld")}) assert device is not None assert device.name == device_name diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 0f1be02875c..e33d626c5d8 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -1134,14 +1134,15 @@ async def test_entity_id_update_discovery_update( async def test_entity_device_info_with_hub( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test MQTT sensor device registry integration.""" await mqtt_mock_entry() other_config_entry = MockConfigEntry() other_config_entry.add_to_hass(hass) - registry = dr.async_get(hass) - hub = registry.async_get_or_create( + hub = device_registry.async_get_or_create( config_entry_id=other_config_entry.entry_id, connections=set(), identifiers={("mqtt", "hub-id")}, @@ -1160,7 +1161,7 @@ async def test_entity_device_info_with_hub( async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.via_device_id == hub.id diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index 55eac636edb..0476c880b1a 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -444,11 +444,12 @@ async def test_not_fires_on_mqtt_message_after_remove_from_registry( async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test MQTT device registry integration.""" await mqtt_mock_entry() - registry = dr.async_get(hass) data = json.dumps( { @@ -466,7 +467,7 @@ async def test_entity_device_info_with_connection( async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device( + device = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, "02:5b:26:a8:dc:12")} ) assert device is not None @@ -479,11 +480,12 @@ async def test_entity_device_info_with_connection( async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test MQTT device registry integration.""" await mqtt_mock_entry() - registry = dr.async_get(hass) data = json.dumps( { @@ -501,7 +503,7 @@ async def test_entity_device_info_with_identifier( async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.identifiers == {("mqtt", "helloworld")} assert device.manufacturer == "Whatever" @@ -512,11 +514,12 @@ async def test_entity_device_info_with_identifier( async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test device registry update.""" await mqtt_mock_entry() - registry = dr.async_get(hass) config = { "topic": "test-topic", @@ -534,7 +537,7 @@ async def test_entity_device_info_update( async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.name == "Beer" @@ -543,7 +546,7 @@ async def test_entity_device_info_update( async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.name == "Milk" diff --git a/tests/components/mqtt_room/test_sensor.py b/tests/components/mqtt_room/test_sensor.py index 72540f49ca7..822e028f4f6 100644 --- a/tests/components/mqtt_room/test_sensor.py +++ b/tests/components/mqtt_room/test_sensor.py @@ -118,7 +118,7 @@ async def test_room_update(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> async def test_unique_id_is_set( - hass: HomeAssistant, mqtt_mock: MqttMockHAClient + hass: HomeAssistant, entity_registry: er.EntityRegistry, mqtt_mock: MqttMockHAClient ) -> None: """Test the updating between rooms.""" unique_name = "my_unique_name_0123456789" @@ -141,6 +141,5 @@ async def test_unique_id_is_set( state = hass.states.get(SENSOR_STATE) assert state.state is not None - entity_registry = er.async_get(hass) entry = entity_registry.async_get(SENSOR_STATE) assert entry.unique_id == unique_name diff --git a/tests/components/myq/fixtures/devices.json b/tests/components/myq/fixtures/devices.json deleted file mode 100644 index 0966845e3ca..00000000000 --- a/tests/components/myq/fixtures/devices.json +++ /dev/null @@ -1,163 +0,0 @@ -{ - "count": 6, - "href": "http://api.myqdevice.com/api/v5/accounts/account_id/devices", - "items": [ - { - "device_type": "ethernetgateway", - "created_date": "2020-02-10T22:54:58.423", - "href": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gateway_serial", - "device_family": "gateway", - "name": "Happy place", - "device_platform": "myq", - "state": { - "homekit_enabled": false, - "pending_bootload_abandoned": false, - "online": true, - "last_status": "2020-03-30T02:49:46.4121303Z", - "physical_devices": [], - "firmware_version": "1.6", - "learn_mode": false, - "learn": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gateway_serial/learn", - "homekit_capable": false, - "updated_date": "2020-03-30T02:49:46.4171299Z" - }, - "serial_number": "gateway_serial" - }, - { - "serial_number": "gate_serial", - "state": { - "report_ajar": false, - "aux_relay_delay": "00:00:00", - "is_unattended_close_allowed": true, - "door_ajar_interval": "00:00:00", - "aux_relay_behavior": "None", - "last_status": "2020-03-30T02:47:40.2794038Z", - "online": true, - "rex_fires_door": false, - "close": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gate_serial/close", - "invalid_shutout_period": "00:00:00", - "invalid_credential_window": "00:00:00", - "use_aux_relay": false, - "command_channel_report_status": false, - "last_update": "2020-03-28T23:07:39.5611776Z", - "door_state": "closed", - "max_invalid_attempts": 0, - "open": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gate_serial/open", - "passthrough_interval": "00:00:00", - "control_from_browser": false, - "report_forced": false, - "is_unattended_open_allowed": true - }, - "parent_device_id": "gateway_serial", - "name": "Gate", - "device_platform": "myq", - "device_family": "garagedoor", - "parent_device": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gateway_serial", - "href": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gate_serial", - "device_type": "gate", - "created_date": "2020-02-10T22:54:58.423" - }, - { - "parent_device": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gateway_serial", - "href": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/large_garage_serial", - "device_type": "wifigaragedooropener", - "created_date": "2020-02-10T22:55:25.863", - "device_platform": "myq", - "name": "Large Garage Door", - "device_family": "garagedoor", - "serial_number": "large_garage_serial", - "state": { - "report_forced": false, - "is_unattended_open_allowed": true, - "passthrough_interval": "00:00:00", - "control_from_browser": false, - "attached_work_light_error_present": false, - "max_invalid_attempts": 0, - "open": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/large_garage_serial/open", - "command_channel_report_status": false, - "last_update": "2020-03-28T23:58:55.5906643Z", - "door_state": "closed", - "invalid_shutout_period": "00:00:00", - "use_aux_relay": false, - "invalid_credential_window": "00:00:00", - "rex_fires_door": false, - "close": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/large_garage_serial/close", - "online": true, - "last_status": "2020-03-30T02:49:46.4121303Z", - "aux_relay_behavior": "None", - "door_ajar_interval": "00:00:00", - "gdo_lock_connected": false, - "report_ajar": false, - "aux_relay_delay": "00:00:00", - "is_unattended_close_allowed": true - }, - "parent_device_id": "gateway_serial" - }, - { - "serial_number": "small_garage_serial", - "state": { - "last_status": "2020-03-30T02:48:45.7501595Z", - "online": true, - "report_ajar": false, - "aux_relay_delay": "00:00:00", - "is_unattended_close_allowed": true, - "gdo_lock_connected": false, - "door_ajar_interval": "00:00:00", - "aux_relay_behavior": "None", - "attached_work_light_error_present": false, - "control_from_browser": false, - "passthrough_interval": "00:00:00", - "is_unattended_open_allowed": true, - "report_forced": false, - "close": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/small_garage_serial/close", - "rex_fires_door": false, - "invalid_credential_window": "00:00:00", - "use_aux_relay": false, - "invalid_shutout_period": "00:00:00", - "door_state": "closed", - "last_update": "2020-03-26T15:45:31.4713796Z", - "command_channel_report_status": false, - "open": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/small_garage_serial/open", - "max_invalid_attempts": 0 - }, - "parent_device_id": "gateway_serial", - "device_platform": "myq", - "name": "Small Garage Door", - "device_family": "garagedoor", - "parent_device": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gateway_serial", - "href": "http://api.myqdevice.com/api/v5/accounts/account_id/devices/small_garage_serial", - "device_type": "wifigaragedooropener", - "created_date": "2020-02-10T23:11:47.487" - }, - { - "serial_number": "garage_light_off", - "state": { - "last_status": "2020-03-30T02:48:45.7501595Z", - "online": true, - "lamp_state": "off", - "last_update": "2020-03-26T15:45:31.4713796Z" - }, - "parent_device_id": "gateway_serial", - "device_platform": "myq", - "name": "Garage Door Light Off", - "device_family": "lamp", - "device_type": "lamp", - "created_date": "2020-02-10T23:11:47.487" - }, - { - "serial_number": "garage_light_on", - "state": { - "last_status": "2020-03-30T02:48:45.7501595Z", - "online": true, - "lamp_state": "on", - "last_update": "2020-03-26T15:45:31.4713796Z" - }, - "parent_device_id": "gateway_serial", - "device_platform": "myq", - "name": "Garage Door Light On", - "device_family": "lamp", - "device_type": "lamp", - "created_date": "2020-02-10T23:11:47.487" - } - ] -} diff --git a/tests/components/myq/test_binary_sensor.py b/tests/components/myq/test_binary_sensor.py deleted file mode 100644 index 39a2a4dff3a..00000000000 --- a/tests/components/myq/test_binary_sensor.py +++ /dev/null @@ -1,20 +0,0 @@ -"""The scene tests for the myq platform.""" -from homeassistant.const import STATE_ON -from homeassistant.core import HomeAssistant - -from .util import async_init_integration - - -async def test_create_binary_sensors(hass: HomeAssistant) -> None: - """Test creation of binary_sensors.""" - - await async_init_integration(hass) - - state = hass.states.get("binary_sensor.happy_place_myq_gateway") - assert state.state == STATE_ON - expected_attributes = {"device_class": "connectivity"} - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes - ) diff --git a/tests/components/myq/test_config_flow.py b/tests/components/myq/test_config_flow.py deleted file mode 100644 index 2df69168852..00000000000 --- a/tests/components/myq/test_config_flow.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Test the MyQ config flow.""" -from unittest.mock import patch - -from pymyq.errors import InvalidCredentialsError, MyQError - -from homeassistant import config_entries -from homeassistant.components.myq.const import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - - -async def test_form_user(hass: HomeAssistant) -> None: - """Test we get the user form.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == "form" - assert result["errors"] == {} - - with patch( - "homeassistant.components.myq.config_flow.pymyq.login", - return_value=True, - ), patch( - "homeassistant.components.myq.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - await hass.async_block_till_done() - - assert result2["type"] == "create_entry" - assert result2["title"] == "test-username" - assert result2["data"] == { - "username": "test-username", - "password": "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.myq.config_flow.pymyq.login", - side_effect=InvalidCredentialsError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - - assert result2["type"] == "form" - assert result2["errors"] == {"password": "invalid_auth"} - - -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.myq.config_flow.pymyq.login", - side_effect=MyQError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - - assert result2["type"] == "form" - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_unknown_exception(hass: HomeAssistant) -> None: - """Test we handle unknown exceptions.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.myq.config_flow.pymyq.login", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - - assert result2["type"] == "form" - assert result2["errors"] == {"base": "unknown"} - - -async def test_reauth(hass: HomeAssistant) -> None: - """Test we can reauth.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_USERNAME: "test@test.org", - CONF_PASSWORD: "secret", - }, - unique_id="test@test.org", - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_REAUTH, "unique_id": "test@test.org"}, - ) - - assert result["type"] == "form" - assert result["step_id"] == "reauth_confirm" - - with patch( - "homeassistant.components.myq.config_flow.pymyq.login", - side_effect=InvalidCredentialsError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] == "form" - assert result2["errors"] == {"password": "invalid_auth"} - - with patch( - "homeassistant.components.myq.config_flow.pymyq.login", - side_effect=MyQError, - ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - { - CONF_PASSWORD: "test-password", - }, - ) - - assert result3["type"] == "form" - assert result3["errors"] == {"base": "cannot_connect"} - - with patch( - "homeassistant.components.myq.config_flow.pymyq.login", - return_value=True, - ), patch( - "homeassistant.components.myq.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - { - CONF_PASSWORD: "test-password", - }, - ) - - assert mock_setup_entry.called - assert result4["type"] == "abort" - assert result4["reason"] == "reauth_successful" diff --git a/tests/components/myq/test_cover.py b/tests/components/myq/test_cover.py deleted file mode 100644 index b8d6cf53736..00000000000 --- a/tests/components/myq/test_cover.py +++ /dev/null @@ -1,50 +0,0 @@ -"""The scene tests for the myq platform.""" -from homeassistant.const import STATE_CLOSED -from homeassistant.core import HomeAssistant - -from .util import async_init_integration - - -async def test_create_covers(hass: HomeAssistant) -> None: - """Test creation of covers.""" - - await async_init_integration(hass) - - state = hass.states.get("cover.large_garage_door") - assert state.state == STATE_CLOSED - expected_attributes = { - "device_class": "garage", - "friendly_name": "Large Garage Door", - "supported_features": 3, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes - ) - - state = hass.states.get("cover.small_garage_door") - assert state.state == STATE_CLOSED - expected_attributes = { - "device_class": "garage", - "friendly_name": "Small Garage Door", - "supported_features": 3, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes - ) - - state = hass.states.get("cover.gate") - assert state.state == STATE_CLOSED - expected_attributes = { - "device_class": "gate", - "friendly_name": "Gate", - "supported_features": 3, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes - ) diff --git a/tests/components/myq/test_init.py b/tests/components/myq/test_init.py new file mode 100644 index 00000000000..24e03f56075 --- /dev/null +++ b/tests/components/myq/test_init.py @@ -0,0 +1,50 @@ +"""Tests for the MyQ Connected Services integration.""" + +from homeassistant.components.myq import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_myq_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the MyQ configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + title="Example 1", + domain=DOMAIN, + ) + config_entry_1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_1.entry_id) + await hass.async_block_till_done() + assert config_entry_1.state is ConfigEntryState.LOADED + + # Add a second one + config_entry_2 = MockConfigEntry( + title="Example 2", + domain=DOMAIN, + ) + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the first one + await hass.config_entries.async_remove(config_entry_1.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None diff --git a/tests/components/myq/test_light.py b/tests/components/myq/test_light.py deleted file mode 100644 index ca80e768779..00000000000 --- a/tests/components/myq/test_light.py +++ /dev/null @@ -1,39 +0,0 @@ -"""The scene tests for the myq platform.""" -from homeassistant.components.light import ColorMode -from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant - -from .util import async_init_integration - - -async def test_create_lights(hass: HomeAssistant) -> None: - """Test creation of lights.""" - - await async_init_integration(hass) - - state = hass.states.get("light.garage_door_light_off") - assert state.state == STATE_OFF - expected_attributes = { - "friendly_name": "Garage Door Light Off", - "supported_features": 0, - "supported_color_modes": [ColorMode.ONOFF], - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes - ) - - state = hass.states.get("light.garage_door_light_on") - assert state.state == STATE_ON - expected_attributes = { - "friendly_name": "Garage Door Light On", - "supported_features": 0, - "supported_color_modes": [ColorMode.ONOFF], - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - - assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes - ) diff --git a/tests/components/myq/util.py b/tests/components/myq/util.py deleted file mode 100644 index 8cb0d17f592..00000000000 --- a/tests/components/myq/util.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Tests for the myq integration.""" -import json -import logging -from unittest.mock import patch - -from pymyq.const import ACCOUNTS_ENDPOINT, DEVICES_ENDPOINT - -from homeassistant.components.myq.const import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry, load_fixture - -_LOGGER = logging.getLogger(__name__) - - -async def async_init_integration( - hass: HomeAssistant, - skip_setup: bool = False, -) -> MockConfigEntry: - """Set up the myq integration in Home Assistant.""" - - devices_fixture = "myq/devices.json" - devices_json = load_fixture(devices_fixture) - devices_dict = json.loads(devices_json) - - def _handle_mock_api_oauth_authenticate(): - return 1234, 1800 - - def _handle_mock_api_request(method, returns, url, **kwargs): - _LOGGER.debug("URL: %s", url) - if url == ACCOUNTS_ENDPOINT: - _LOGGER.debug("Accounts") - return None, {"accounts": [{"id": 1, "name": "mock"}]} - if url == DEVICES_ENDPOINT.format(account_id=1): - _LOGGER.debug("Devices") - return None, devices_dict - _LOGGER.debug("Something else") - return None, {} - - with patch( - "pymyq.api.API._oauth_authenticate", - side_effect=_handle_mock_api_oauth_authenticate, - ), patch("pymyq.api.API.request", side_effect=_handle_mock_api_request): - entry = MockConfigEntry( - domain=DOMAIN, data={CONF_USERNAME: "mock", CONF_PASSWORD: "mock"} - ) - entry.add_to_hass(hass) - - if not skip_setup: - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - return entry diff --git a/tests/components/mysensors/conftest.py b/tests/components/mysensors/conftest.py index 883a94ea02e..64fbb61aac3 100644 --- a/tests/components/mysensors/conftest.py +++ b/tests/components/mysensors/conftest.py @@ -59,7 +59,8 @@ async def serial_transport_fixture( ) as transport_class, patch("mysensors.task.OTAFirmware", autospec=True), patch( "mysensors.task.load_fw", autospec=True ), patch( - "mysensors.task.Persistence", autospec=True + "mysensors.task.Persistence", + autospec=True, ) as persistence_class: persistence = persistence_class.return_value diff --git a/tests/components/mysensors/test_init.py b/tests/components/mysensors/test_init.py index 9d1867b3158..fd61e27a663 100644 --- a/tests/components/mysensors/test_init.py +++ b/tests/components/mysensors/test_init.py @@ -15,6 +15,8 @@ from tests.typing import WebSocketGenerator async def test_remove_config_entry_device( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, gps_sensor: Sensor, integration: MockConfigEntry, gateway: BaseSyncGateway, @@ -27,11 +29,9 @@ async def test_remove_config_entry_device( assert await async_setup_component(hass, "config", {}) await hass.async_block_till_done() - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"{config_entry.entry_id}-{node_id}")} ) - entity_registry = er.async_get(hass) state = hass.states.get(entity_id) assert gateway.sensors diff --git a/tests/components/mystrom/test_config_flow.py b/tests/components/mystrom/test_config_flow.py index 97823681b8e..9459519de75 100644 --- a/tests/components/mystrom/test_config_flow.py +++ b/tests/components/mystrom/test_config_flow.py @@ -6,7 +6,6 @@ import pytest from homeassistant import config_entries from homeassistant.components.mystrom.const import DOMAIN -from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -72,26 +71,6 @@ async def test_form_duplicates( mock_session.assert_called_once() -async def test_step_import(hass: HomeAssistant) -> None: - """Test the import step.""" - conf = { - CONF_HOST: "1.1.1.1", - } - with patch("pymystrom.switch.MyStromSwitch.get_state"), patch( - "pymystrom.get_device_info", - return_value={"type": 101, "mac": DEVICE_MAC}, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf - ) - await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "myStrom Device" - assert result["data"] == { - CONF_HOST: "1.1.1.1", - } - - async def test_wong_answer_from_device(hass: HomeAssistant) -> None: """Test handling of wrong answers from the device.""" diff --git a/tests/components/nam/test_button.py b/tests/components/nam/test_button.py index 4a1083874d0..ab4e46975f9 100644 --- a/tests/components/nam/test_button.py +++ b/tests/components/nam/test_button.py @@ -10,10 +10,8 @@ from homeassistant.util import dt as dt_util from . import init_integration -async def test_button(hass: HomeAssistant) -> None: +async def test_button(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test states of the button.""" - registry = er.async_get(hass) - await init_integration(hass) state = hass.states.get("button.nettigo_air_monitor_restart") @@ -21,7 +19,7 @@ async def test_button(hass: HomeAssistant) -> None: assert state.state == STATE_UNKNOWN assert state.attributes.get(ATTR_DEVICE_CLASS) == ButtonDeviceClass.RESTART - entry = registry.async_get("button.nettigo_air_monitor_restart") + entry = entity_registry.async_get("button.nettigo_air_monitor_restart") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-restart" diff --git a/tests/components/nam/test_init.py b/tests/components/nam/test_init.py index dbd1c152d6b..63034d5b075 100644 --- a/tests/components/nam/test_init.py +++ b/tests/components/nam/test_init.py @@ -93,11 +93,11 @@ async def test_unload_entry(hass: HomeAssistant) -> None: assert not hass.data.get(DOMAIN) -async def test_remove_air_quality_entities(hass: HomeAssistant) -> None: +async def test_remove_air_quality_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test remove air_quality entities from registry.""" - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( AIR_QUALITY_PLATFORM, DOMAIN, "aa:bb:cc:dd:ee:ff-sds011", @@ -105,7 +105,7 @@ async def test_remove_air_quality_entities(hass: HomeAssistant) -> None: disabled_by=None, ) - registry.async_get_or_create( + entity_registry.async_get_or_create( AIR_QUALITY_PLATFORM, DOMAIN, "aa:bb:cc:dd:ee:ff-sps30", @@ -115,8 +115,8 @@ async def test_remove_air_quality_entities(hass: HomeAssistant) -> None: await init_integration(hass) - entry = registry.async_get("air_quality.nettigo_air_monitor_sds011") + entry = entity_registry.async_get("air_quality.nettigo_air_monitor_sds011") assert entry is None - entry = registry.async_get("air_quality.nettigo_air_monitor_sps30") + entry = entity_registry.async_get("air_quality.nettigo_air_monitor_sps30") assert entry is None diff --git a/tests/components/nam/test_sensor.py b/tests/components/nam/test_sensor.py index 4f1b95ea206..50cf3aba659 100644 --- a/tests/components/nam/test_sensor.py +++ b/tests/components/nam/test_sensor.py @@ -35,11 +35,9 @@ from . import INCOMPLETE_NAM_DATA, init_integration, nam_data from tests.common import async_fire_time_changed -async def test_sensor(hass: HomeAssistant) -> None: +async def test_sensor(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test states of the air_quality.""" - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, "aa:bb:cc:dd:ee:ff-signal", @@ -47,7 +45,7 @@ async def test_sensor(hass: HomeAssistant) -> None: disabled_by=None, ) - registry.async_get_or_create( + entity_registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, "aa:bb:cc:dd:ee:ff-uptime", @@ -67,7 +65,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - entry = registry.async_get("sensor.nettigo_air_monitor_bme280_humidity") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_bme280_humidity") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-bme280_humidity" @@ -78,7 +76,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - entry = registry.async_get("sensor.nettigo_air_monitor_bme280_temperature") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_bme280_temperature") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-bme280_temperature" @@ -89,7 +87,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPressure.HPA - entry = registry.async_get("sensor.nettigo_air_monitor_bme280_pressure") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_bme280_pressure") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-bme280_pressure" @@ -100,7 +98,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - entry = registry.async_get("sensor.nettigo_air_monitor_bmp180_temperature") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_bmp180_temperature") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-bmp180_temperature" @@ -111,7 +109,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPressure.HPA - entry = registry.async_get("sensor.nettigo_air_monitor_bmp180_pressure") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_bmp180_pressure") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-bmp180_pressure" @@ -122,7 +120,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - entry = registry.async_get("sensor.nettigo_air_monitor_bmp280_temperature") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_bmp280_temperature") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-bmp280_temperature" @@ -133,7 +131,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPressure.HPA - entry = registry.async_get("sensor.nettigo_air_monitor_bmp280_pressure") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_bmp280_pressure") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-bmp280_pressure" @@ -144,7 +142,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - entry = registry.async_get("sensor.nettigo_air_monitor_sht3x_humidity") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_sht3x_humidity") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sht3x_humidity" @@ -155,7 +153,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - entry = registry.async_get("sensor.nettigo_air_monitor_sht3x_temperature") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_sht3x_temperature") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sht3x_temperature" @@ -166,7 +164,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - entry = registry.async_get("sensor.nettigo_air_monitor_dht22_humidity") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_dht22_humidity") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-dht22_humidity" @@ -177,7 +175,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - entry = registry.async_get("sensor.nettigo_air_monitor_dht22_temperature") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_dht22_temperature") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-dht22_temperature" @@ -188,7 +186,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - entry = registry.async_get("sensor.nettigo_air_monitor_heca_humidity") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_heca_humidity") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-heca_humidity" @@ -199,7 +197,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - entry = registry.async_get("sensor.nettigo_air_monitor_heca_temperature") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_heca_temperature") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-heca_temperature" @@ -213,7 +211,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == SIGNAL_STRENGTH_DECIBELS_MILLIWATT ) - entry = registry.async_get("sensor.nettigo_air_monitor_signal_strength") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_signal_strength") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-signal" @@ -226,7 +224,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.nettigo_air_monitor_uptime") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_uptime") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-uptime" @@ -245,7 +243,7 @@ async def test_sensor(hass: HomeAssistant) -> None: ] assert state.attributes.get(ATTR_ICON) == "mdi:air-filter" - entry = registry.async_get( + entry = entity_registry.async_get( "sensor.nettigo_air_monitor_pmsx003_common_air_quality_index_level" ) assert entry @@ -259,7 +257,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.state == "19" assert state.attributes.get(ATTR_ICON) == "mdi:air-filter" - entry = registry.async_get( + entry = entity_registry.async_get( "sensor.nettigo_air_monitor_pmsx003_common_air_quality_index" ) assert entry @@ -275,7 +273,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.nettigo_air_monitor_pmsx003_pm10") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_pmsx003_pm10") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-pms_p1" @@ -289,7 +287,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.nettigo_air_monitor_pmsx003_pm2_5") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_pmsx003_pm2_5") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-pms_p2" @@ -303,7 +301,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.nettigo_air_monitor_pmsx003_pm1") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_pmsx003_pm1") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-pms_p0" @@ -317,7 +315,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.nettigo_air_monitor_sds011_pm10") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_sds011_pm10") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sds011_p1" @@ -328,7 +326,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.state == "19" assert state.attributes.get(ATTR_ICON) == "mdi:air-filter" - entry = registry.async_get( + entry = entity_registry.async_get( "sensor.nettigo_air_monitor_sds011_common_air_quality_index" ) assert entry @@ -349,7 +347,7 @@ async def test_sensor(hass: HomeAssistant) -> None: ] assert state.attributes.get(ATTR_ICON) == "mdi:air-filter" - entry = registry.async_get( + entry = entity_registry.async_get( "sensor.nettigo_air_monitor_sds011_common_air_quality_index_level" ) assert entry @@ -366,7 +364,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.nettigo_air_monitor_sds011_pm2_5") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_sds011_pm2_5") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sds011_p2" @@ -375,7 +373,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state.state == "54" assert state.attributes.get(ATTR_ICON) == "mdi:air-filter" - entry = registry.async_get( + entry = entity_registry.async_get( "sensor.nettigo_air_monitor_sps30_common_air_quality_index" ) assert entry @@ -396,7 +394,7 @@ async def test_sensor(hass: HomeAssistant) -> None: ] assert state.attributes.get(ATTR_ICON) == "mdi:air-filter" - entry = registry.async_get( + entry = entity_registry.async_get( "sensor.nettigo_air_monitor_sps30_common_air_quality_index_level" ) assert entry @@ -413,7 +411,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.nettigo_air_monitor_sps30_pm1") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_sps30_pm1") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sps30_p0" @@ -427,7 +425,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.nettigo_air_monitor_sps30_pm10") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_sps30_pm10") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sps30_p1" @@ -441,7 +439,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.nettigo_air_monitor_sps30_pm2_5") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_sps30_pm2_5") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sps30_p2" @@ -455,7 +453,7 @@ async def test_sensor(hass: HomeAssistant) -> None: ) assert state.attributes.get(ATTR_ICON) == "mdi:molecule" - entry = registry.async_get("sensor.nettigo_air_monitor_sps30_pm4") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_sps30_pm4") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sps30_p4" @@ -468,24 +466,27 @@ async def test_sensor(hass: HomeAssistant) -> None: state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_PARTS_PER_MILLION ) - entry = registry.async_get("sensor.nettigo_air_monitor_mh_z14a_carbon_dioxide") + entry = entity_registry.async_get( + "sensor.nettigo_air_monitor_mh_z14a_carbon_dioxide" + ) assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-mhz14a_carbon_dioxide" -async def test_sensor_disabled(hass: HomeAssistant) -> None: +async def test_sensor_disabled( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test sensor disabled by default.""" await init_integration(hass) - registry = er.async_get(hass) - entry = registry.async_get("sensor.nettigo_air_monitor_signal_strength") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_signal_strength") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-signal" assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Test enabling entity - updated_entry = registry.async_update_entity( + updated_entry = entity_registry.async_update_entity( entry.entity_id, **{"disabled_by": None} ) @@ -574,11 +575,11 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None: assert mock_get_data.call_count == 1 -async def test_unique_id_migration(hass: HomeAssistant) -> None: +async def test_unique_id_migration( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test states of the unique_id migration.""" - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, "aa:bb:cc:dd:ee:ff-temperature", @@ -586,7 +587,7 @@ async def test_unique_id_migration(hass: HomeAssistant) -> None: disabled_by=None, ) - registry.async_get_or_create( + entity_registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, "aa:bb:cc:dd:ee:ff-humidity", @@ -596,10 +597,10 @@ async def test_unique_id_migration(hass: HomeAssistant) -> None: await init_integration(hass) - entry = registry.async_get("sensor.nettigo_air_monitor_dht22_temperature") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_dht22_temperature") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-dht22_temperature" - entry = registry.async_get("sensor.nettigo_air_monitor_dht22_humidity") + entry = entity_registry.async_get("sensor.nettigo_air_monitor_dht22_humidity") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-dht22_humidity" diff --git a/tests/components/nest/test_config_flow.py b/tests/components/nest/test_config_flow.py index 7ab4a6dafc1..b483b752e77 100644 --- a/tests/components/nest/test_config_flow.py +++ b/tests/components/nest/test_config_flow.py @@ -1,6 +1,7 @@ """Test the Google Nest Device Access config flow.""" from __future__ import annotations +from http import HTTPStatus from typing import Any from unittest.mock import patch @@ -27,7 +28,9 @@ from .common import ( SUBSCRIBER_ID, TEST_CONFIG_APP_CREDS, TEST_CONFIGFLOW_APP_CREDS, + FakeSubscriber, NestTestConfig, + PlatformSetup, ) from tests.common import MockConfigEntry @@ -92,8 +95,6 @@ class OAuthFixture: assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" - await self.async_mock_refresh(result) - async def async_reauth(self, config_entry: ConfigEntry) -> dict: """Initiate a reuath flow.""" config_entry.async_start_reauth(self.hass) @@ -137,7 +138,7 @@ class OAuthFixture: "&access_type=offline&prompt=consent" ) - async def async_mock_refresh(self, result, user_input: dict = None) -> None: + def async_mock_refresh(self) -> None: """Finish the OAuth flow exchanging auth token for refresh token.""" self.aioclient_mock.post( OAUTH2_TOKEN, @@ -202,6 +203,7 @@ async def test_app_credentials( DOMAIN, context={"source": config_entries.SOURCE_USER} ) await oauth.async_app_creds_flow(result) + oauth.async_mock_refresh() entry = await oauth.async_finish_setup(result) @@ -235,6 +237,7 @@ async def test_config_flow_restart( DOMAIN, context={"source": config_entries.SOURCE_USER} ) await oauth.async_app_creds_flow(result) + oauth.async_mock_refresh() # At this point, we should have a valid auth implementation configured. # Simulate aborting the flow and starting over to ensure we get prompted @@ -254,6 +257,7 @@ async def test_config_flow_restart( result = await oauth.async_configure(result, {"project_id": "new-project-id"}) await oauth.async_oauth_web_flow(result, "new-project-id") + oauth.async_mock_refresh() entry = await oauth.async_finish_setup(result, {"code": "1234"}) @@ -305,6 +309,7 @@ async def test_config_flow_wrong_project_id( result = await oauth.async_configure(result, {"project_id": PROJECT_ID}) await oauth.async_oauth_web_flow(result) await hass.async_block_till_done() + oauth.async_mock_refresh() entry = await oauth.async_finish_setup(result, {"code": "1234"}) @@ -341,6 +346,7 @@ async def test_config_flow_pubsub_configuration_error( DOMAIN, context={"source": config_entries.SOURCE_USER} ) await oauth.async_app_creds_flow(result) + oauth.async_mock_refresh() mock_subscriber.create_subscription.side_effect = ConfigurationException result = await oauth.async_configure(result, {"code": "1234"}) @@ -360,6 +366,7 @@ async def test_config_flow_pubsub_subscriber_error( DOMAIN, context={"source": config_entries.SOURCE_USER} ) await oauth.async_app_creds_flow(result) + oauth.async_mock_refresh() mock_subscriber.create_subscription.side_effect = SubscriberException() result = await oauth.async_configure(result, {"code": "1234"}) @@ -384,6 +391,7 @@ async def test_multiple_config_entries( DOMAIN, context={"source": config_entries.SOURCE_USER} ) await oauth.async_app_creds_flow(result, project_id="project-id-2") + oauth.async_mock_refresh() entry = await oauth.async_finish_setup(result) assert entry.title == "Mock Title" assert "token" in entry.data @@ -442,6 +450,7 @@ async def test_reauth_multiple_config_entries( result = await oauth.async_reauth(config_entry) await oauth.async_oauth_web_flow(result) + oauth.async_mock_refresh() await oauth.async_finish_setup(result) @@ -479,6 +488,7 @@ async def test_pubsub_subscription_strip_whitespace( await oauth.async_app_creds_flow( result, cloud_project_id=" " + CLOUD_PROJECT_ID + " " ) + oauth.async_mock_refresh() entry = await oauth.async_finish_setup(result, {"code": "1234"}) assert entry.title == "Import from configuration.yaml" @@ -508,6 +518,7 @@ async def test_pubsub_subscription_auth_failure( mock_subscriber.create_subscription.side_effect = AuthException() await oauth.async_app_creds_flow(result) + oauth.async_mock_refresh() result = await oauth.async_configure(result, {"code": "1234"}) assert result["type"] == "abort" @@ -527,6 +538,7 @@ async def test_pubsub_subscriber_config_entry_reauth( result = await oauth.async_reauth(config_entry) await oauth.async_oauth_web_flow(result) + oauth.async_mock_refresh() # Entering an updated access token refreshes the config entry. entry = await oauth.async_finish_setup(result, {"code": "1234"}) @@ -568,6 +580,7 @@ async def test_config_entry_title_from_home( DOMAIN, context={"source": config_entries.SOURCE_USER} ) await oauth.async_app_creds_flow(result) + oauth.async_mock_refresh() entry = await oauth.async_finish_setup(result, {"code": "1234"}) assert entry.title == "Example Home" @@ -613,6 +626,7 @@ async def test_config_entry_title_multiple_homes( DOMAIN, context={"source": config_entries.SOURCE_USER} ) await oauth.async_app_creds_flow(result) + oauth.async_mock_refresh() entry = await oauth.async_finish_setup(result, {"code": "1234"}) assert entry.title == "Example Home #1, Example Home #2" @@ -628,6 +642,7 @@ async def test_title_failure_fallback( DOMAIN, context={"source": config_entries.SOURCE_USER} ) await oauth.async_app_creds_flow(result) + oauth.async_mock_refresh() mock_subscriber.async_get_device_manager.side_effect = AuthException() entry = await oauth.async_finish_setup(result, {"code": "1234"}) @@ -659,6 +674,7 @@ async def test_structure_missing_trait( DOMAIN, context={"source": config_entries.SOURCE_USER} ) await oauth.async_app_creds_flow(result) + oauth.async_mock_refresh() entry = await oauth.async_finish_setup(result, {"code": "1234"}) # Fallback to default name @@ -705,6 +721,7 @@ async def test_dhcp_discovery_with_creds( result = await oauth.async_configure(result, {"project_id": PROJECT_ID}) await oauth.async_oauth_web_flow(result) + oauth.async_mock_refresh() entry = await oauth.async_finish_setup(result, {"code": "1234"}) await hass.async_block_till_done() @@ -726,3 +743,36 @@ async def test_dhcp_discovery_with_creds( "type": "Bearer", }, } + + +@pytest.mark.parametrize( + ("status_code", "error_reason"), + [ + (HTTPStatus.UNAUTHORIZED, "oauth_unauthorized"), + (HTTPStatus.NOT_FOUND, "oauth_failed"), + (HTTPStatus.INTERNAL_SERVER_ERROR, "oauth_failed"), + ], +) +async def test_token_error( + hass: HomeAssistant, + oauth: OAuthFixture, + subscriber: FakeSubscriber, + setup_platform: PlatformSetup, + status_code: HTTPStatus, + error_reason: str, +) -> None: + """Check full flow.""" + await setup_platform() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await oauth.async_app_creds_flow(result) + oauth.aioclient_mock.post( + OAUTH2_TOKEN, + status=status_code, + ) + + result = await oauth.async_configure(result, user_input=None) + assert result.get("type") == "abort" + assert result.get("reason") == error_reason diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index 0776b80a3cd..61a7bc2354d 100644 --- a/tests/components/netatmo/common.py +++ b/tests/components/netatmo/common.py @@ -97,6 +97,6 @@ def selected_platforms(platforms): ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.netatmo.webhook_generate_url" + "homeassistant.components.netatmo.webhook_generate_url", ): yield diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index e9a66cfefc8..6dcc11d31ab 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -388,7 +388,7 @@ async def test_camera_reconnect_webhook(hass: HomeAssistant, config_entry) -> No ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.netatmo.webhook_generate_url" + "homeassistant.components.netatmo.webhook_generate_url", ) as mock_webhook: mock_auth.return_value.async_post_api_request.side_effect = fake_post mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() @@ -482,7 +482,7 @@ async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) -> ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.netatmo.webhook_generate_url" + "homeassistant.components.netatmo.webhook_generate_url", ): mock_auth.return_value.async_post_api_request.side_effect = fake_post_no_data mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() @@ -522,7 +522,7 @@ async def test_camera_image_raises_exception( ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.netatmo.webhook_generate_url" + "homeassistant.components.netatmo.webhook_generate_url", ): mock_auth.return_value.async_post_api_request.side_effect = fake_post mock_auth.return_value.async_get_image.side_effect = fake_post diff --git a/tests/components/netatmo/test_climate.py b/tests/components/netatmo/test_climate.py index 99000403a38..848aad331bd 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -22,8 +22,14 @@ from homeassistant.components.netatmo.climate import PRESET_FROST_GUARD, PRESET_ from homeassistant.components.netatmo.const import ( ATTR_END_DATETIME, ATTR_SCHEDULE_NAME, + ATTR_TARGET_TEMPERATURE, + ATTR_TIME_PERIOD, + DOMAIN as NETATMO_DOMAIN, + SERVICE_CLEAR_TEMPERATURE_SETTING, SERVICE_SET_PRESET_MODE_WITH_END_DATETIME, SERVICE_SET_SCHEDULE, + SERVICE_SET_TEMPERATURE_WITH_END_DATETIME, + SERVICE_SET_TEMPERATURE_WITH_TIME_PERIOD, ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant @@ -359,6 +365,203 @@ async def test_service_preset_modes_thermostat( assert hass.states.get(climate_entity_livingroom).attributes["temperature"] == 30 +async def test_service_set_temperature_with_end_datetime( + hass: HomeAssistant, config_entry, netatmo_auth +) -> None: + """Test service setting temperature with an end datetime.""" + with selected_platforms(["climate"]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + webhook_id = config_entry.data[CONF_WEBHOOK_ID] + climate_entity_livingroom = "climate.livingroom" + + assert hass.states.get(climate_entity_livingroom).state == "auto" + + # Test service setting the temperature without an end datetime + await hass.services.async_call( + NETATMO_DOMAIN, + SERVICE_SET_TEMPERATURE_WITH_END_DATETIME, + { + ATTR_ENTITY_ID: climate_entity_livingroom, + ATTR_TARGET_TEMPERATURE: 25, + ATTR_END_DATETIME: "2023-11-17 12:23:00", + }, + blocking=True, + ) + await hass.async_block_till_done() + + # Test webhook room mode change to "manual" + response = { + "room_id": "2746182631", + "home": { + "id": "91763b24c43d3e344f424e8b", + "name": "MYHOME", + "country": "DE", + "rooms": [ + { + "id": "2746182631", + "name": "Livingroom", + "type": "livingroom", + "therm_setpoint_mode": "manual", + "therm_setpoint_temperature": 25, + "therm_setpoint_end_time": 1612749189, + } + ], + "modules": [ + {"id": "12:34:56:00:01:ae", "name": "Livingroom", "type": "NATherm1"} + ], + }, + "mode": "manual", + "event_type": "set_point", + "push_type": "display_change", + } + await simulate_webhook(hass, webhook_id, response) + + assert hass.states.get(climate_entity_livingroom).state == "heat" + assert hass.states.get(climate_entity_livingroom).attributes["temperature"] == 25 + + +async def test_service_set_temperature_with_time_period( + hass: HomeAssistant, config_entry, netatmo_auth +) -> None: + """Test service setting temperature with an end datetime.""" + with selected_platforms(["climate"]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + webhook_id = config_entry.data[CONF_WEBHOOK_ID] + climate_entity_livingroom = "climate.livingroom" + + assert hass.states.get(climate_entity_livingroom).state == "auto" + + # Test service setting the temperature without an end datetime + await hass.services.async_call( + NETATMO_DOMAIN, + SERVICE_SET_TEMPERATURE_WITH_TIME_PERIOD, + { + ATTR_ENTITY_ID: climate_entity_livingroom, + ATTR_TARGET_TEMPERATURE: 25, + ATTR_TIME_PERIOD: "02:24:00", + }, + blocking=True, + ) + await hass.async_block_till_done() + + # Test webhook room mode change to "manual" + response = { + "room_id": "2746182631", + "home": { + "id": "91763b24c43d3e344f424e8b", + "name": "MYHOME", + "country": "DE", + "rooms": [ + { + "id": "2746182631", + "name": "Livingroom", + "type": "livingroom", + "therm_setpoint_mode": "manual", + "therm_setpoint_temperature": 25, + "therm_setpoint_end_time": 1612749189, + } + ], + "modules": [ + {"id": "12:34:56:00:01:ae", "name": "Livingroom", "type": "NATherm1"} + ], + }, + "mode": "manual", + "event_type": "set_point", + "push_type": "display_change", + } + await simulate_webhook(hass, webhook_id, response) + + assert hass.states.get(climate_entity_livingroom).state == "heat" + assert hass.states.get(climate_entity_livingroom).attributes["temperature"] == 25 + + +async def test_service_clear_temperature_setting( + hass: HomeAssistant, config_entry, netatmo_auth +) -> None: + """Test service clearing temperature setting.""" + with selected_platforms(["climate"]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + webhook_id = config_entry.data[CONF_WEBHOOK_ID] + climate_entity_livingroom = "climate.livingroom" + + assert hass.states.get(climate_entity_livingroom).state == "auto" + + # Simulate a room thermostat change to manual boost + response = { + "room_id": "2746182631", + "home": { + "id": "91763b24c43d3e344f424e8b", + "name": "MYHOME", + "country": "DE", + "rooms": [ + { + "id": "2746182631", + "name": "Livingroom", + "type": "livingroom", + "therm_setpoint_mode": "manual", + "therm_setpoint_temperature": 25, + "therm_setpoint_end_time": 1612749189, + } + ], + "modules": [ + {"id": "12:34:56:00:01:ae", "name": "Livingroom", "type": "NATherm1"} + ], + }, + "mode": "manual", + "event_type": "set_point", + "push_type": "display_change", + } + await simulate_webhook(hass, webhook_id, response) + + assert hass.states.get(climate_entity_livingroom).state == "heat" + assert hass.states.get(climate_entity_livingroom).attributes["temperature"] == 25 + + # Test service setting the temperature without an end datetime + await hass.services.async_call( + NETATMO_DOMAIN, + SERVICE_CLEAR_TEMPERATURE_SETTING, + {ATTR_ENTITY_ID: climate_entity_livingroom}, + blocking=True, + ) + await hass.async_block_till_done() + + # Test webhook room mode change to "home" + response = { + "room_id": "2746182631", + "home": { + "id": "91763b24c43d3e344f424e8b", + "name": "MYHOME", + "country": "DE", + "rooms": [ + { + "id": "2746182631", + "name": "Livingroom", + "type": "livingroom", + "therm_setpoint_mode": "home", + } + ], + "modules": [ + {"id": "12:34:56:00:01:ae", "name": "Livingroom", "type": "NATherm1"} + ], + }, + "mode": "home", + "event_type": "cancel_set_point", + "push_type": "display_change", + } + await simulate_webhook(hass, webhook_id, response) + + assert hass.states.get(climate_entity_livingroom).state == "auto" + + async def test_webhook_event_handling_no_data( hass: HomeAssistant, config_entry, netatmo_auth ) -> None: diff --git a/tests/components/netatmo/test_diagnostics.py b/tests/components/netatmo/test_diagnostics.py index 0ece935abcb..19f83830a4e 100644 --- a/tests/components/netatmo/test_diagnostics.py +++ b/tests/components/netatmo/test_diagnostics.py @@ -25,7 +25,7 @@ async def test_entry_diagnostics( ) as mock_auth, patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.netatmo.webhook_generate_url" + "homeassistant.components.netatmo.webhook_generate_url", ): mock_auth.return_value.async_post_api_request.side_effect = fake_post_request mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index e04295ae668..75b1e9e47e6 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -205,7 +205,7 @@ async def test_setup_with_cloud(hass: HomeAssistant, config_entry) -> None: ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.netatmo.webhook_generate_url" + "homeassistant.components.netatmo.webhook_generate_url", ): mock_auth.return_value.async_post_api_request.side_effect = fake_post_request assert await async_setup_component( @@ -271,7 +271,7 @@ async def test_setup_with_cloudhook(hass: HomeAssistant) -> None: ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.netatmo.webhook_generate_url" + "homeassistant.components.netatmo.webhook_generate_url", ): mock_auth.return_value.async_post_api_request.side_effect = fake_post_request mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index 83218b6d6d1..b6df9191976 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -103,7 +103,7 @@ async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) -> ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.netatmo.webhook_generate_url" + "homeassistant.components.netatmo.webhook_generate_url", ): mock_auth.return_value.async_post_api_request.side_effect = ( fake_post_request_no_data diff --git a/tests/components/nextbus/conftest.py b/tests/components/nextbus/conftest.py index 0940118c13a..4f6a6f22270 100644 --- a/tests/components/nextbus/conftest.py +++ b/tests/components/nextbus/conftest.py @@ -1,11 +1,37 @@ """Test helpers for NextBus tests.""" +from typing import Any from unittest.mock import MagicMock import pytest +@pytest.fixture( + params=[ + {"name": "Outbound", "stop": [{"tag": "5650"}]}, + [ + { + "name": "Outbound", + "stop": [{"tag": "5650"}], + }, + { + "name": "Inbound", + "stop": [{"tag": "5651"}], + }, + ], + ] +) +def route_config_direction(request: pytest.FixtureRequest) -> Any: + """Generate alternative directions values. + + When only on edirection is returned, it is not returned as a list, but instead an object. + """ + return request.param + + @pytest.fixture -def mock_nextbus_lists(mock_nextbus: MagicMock) -> MagicMock: +def mock_nextbus_lists( + mock_nextbus: MagicMock, route_config_direction: Any +) -> MagicMock: """Mock all list functions in nextbus to test validate logic.""" instance = mock_nextbus.return_value instance.get_agency_list.return_value = { @@ -22,16 +48,7 @@ def mock_nextbus_lists(mock_nextbus: MagicMock) -> MagicMock: # Error case test. Duplicate title with no unique direction {"tag": "5652", "title": "Market St & 7th St"}, ], - "direction": [ - { - "name": "Outbound", - "stop": [{"tag": "5650"}], - }, - { - "name": "Inbound", - "stop": [{"tag": "5651"}], - }, - ], + "direction": route_config_direction, } } diff --git a/tests/components/nibe_heatpump/__init__.py b/tests/components/nibe_heatpump/__init__.py index d2852ec42f5..3c3db391ba8 100644 --- a/tests/components/nibe_heatpump/__init__.py +++ b/tests/components/nibe_heatpump/__init__.py @@ -1,8 +1,12 @@ """Tests for the Nibe Heat Pump integration.""" from typing import Any +from unittest.mock import AsyncMock -from nibe.heatpump import Model +from nibe.coil import Coil, CoilData +from nibe.connection import Connection +from nibe.exceptions import ReadException +from nibe.heatpump import HeatPump, Model from homeassistant.components.nibe_heatpump import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -21,7 +25,39 @@ MOCK_ENTRY_DATA = { } -async def async_add_entry(hass: HomeAssistant, data: dict[str, Any]) -> None: +class MockConnection(Connection): + """A mock connection class.""" + + def __init__(self) -> None: + """Initialize the mock connection.""" + self.coils: dict[int, Any] = {} + self.heatpump: HeatPump + self.start = AsyncMock() + self.stop = AsyncMock() + self.write_coil = AsyncMock() + self.verify_connectivity = AsyncMock() + self.read_product_info = AsyncMock() + + async def read_coil(self, coil: Coil, timeout: float = 0) -> CoilData: + """Read of coils.""" + if (data := self.coils.get(coil.address, None)) is None: + raise ReadException() + return CoilData(coil, data) + + async def write_coil(self, coil_data: CoilData, timeout: float = 10.0) -> None: + """Write a coil data to the heatpump.""" + + async def verify_connectivity(self): + """Verify that we have functioning communication.""" + + def mock_coil_update(self, coil_id: int, value: int | float | str | None): + """Trigger an out of band coil update.""" + coil = self.heatpump.get_coil_by_address(coil_id) + self.coils[coil_id] = value + self.heatpump.notify_coil_update(CoilData(coil, value)) + + +async def async_add_entry(hass: HomeAssistant, data: dict[str, Any]) -> MockConfigEntry: """Add entry and get the coordinator.""" entry = MockConfigEntry(domain=DOMAIN, title="Dummy", data=data) @@ -29,8 +65,9 @@ async def async_add_entry(hass: HomeAssistant, data: dict[str, Any]) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED + return entry -async def async_add_model(hass: HomeAssistant, model: Model): +async def async_add_model(hass: HomeAssistant, model: Model) -> MockConfigEntry: """Add entry of specific model.""" - await async_add_entry(hass, {**MOCK_ENTRY_DATA, "model": model.name}) + return await async_add_entry(hass, {**MOCK_ENTRY_DATA, "model": model.name}) diff --git a/tests/components/nibe_heatpump/conftest.py b/tests/components/nibe_heatpump/conftest.py index d7343eac69c..a5eb5fb012d 100644 --- a/tests/components/nibe_heatpump/conftest.py +++ b/tests/components/nibe_heatpump/conftest.py @@ -1,14 +1,18 @@ """Test configuration for Nibe Heat Pump.""" -from collections.abc import AsyncIterator, Generator, Iterable +from collections.abc import Generator from contextlib import ExitStack -from typing import Any from unittest.mock import AsyncMock, Mock, patch -from nibe.coil import Coil, CoilData -from nibe.connection import Connection -from nibe.exceptions import ReadException +from freezegun.api import FrozenDateTimeFactory +from nibe.exceptions import CoilNotFoundException import pytest +from homeassistant.core import HomeAssistant + +from . import MockConnection + +from tests.common import async_fire_time_changed + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: @@ -19,10 +23,22 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: yield mock_setup_entry -@pytest.fixture(autouse=True, name="mock_connection_constructor") -async def fixture_mock_connection_constructor(): +@pytest.fixture(autouse=True, name="mock_connection_construct") +async def fixture_mock_connection_construct(): + """Fixture to catch constructor calls.""" + return Mock() + + +@pytest.fixture(autouse=True, name="mock_connection") +async def fixture_mock_connection(mock_connection_construct): """Make sure we have a dummy connection.""" - mock_constructor = Mock() + mock_connection = MockConnection() + + def construct(heatpump, *args, **kwargs): + mock_connection_construct(heatpump, *args, **kwargs) + mock_connection.heatpump = heatpump + return mock_connection + with ExitStack() as stack: places = [ "homeassistant.components.nibe_heatpump.config_flow.NibeGW", @@ -31,46 +47,43 @@ async def fixture_mock_connection_constructor(): "homeassistant.components.nibe_heatpump.Modbus", ] for place in places: - stack.enter_context(patch(place, new=mock_constructor)) - yield mock_constructor - - -@pytest.fixture(name="mock_connection") -def fixture_mock_connection(mock_connection_constructor: Mock): - """Make sure we have a dummy connection.""" - mock_connection = AsyncMock(spec=Connection) - mock_connection_constructor.return_value = mock_connection - return mock_connection + stack.enter_context(patch(place, new=construct)) + yield mock_connection @pytest.fixture(name="coils") -async def fixture_coils(mock_connection): +async def fixture_coils(mock_connection: MockConnection): """Return a dict with coil data.""" - coils: dict[int, Any] = {} - - async def read_coil(coil: Coil, timeout: float = 0) -> CoilData: - nonlocal coils - if (data := coils.get(coil.address, None)) is None: - raise ReadException() - return CoilData(coil, data) - - async def read_coils( - coils: Iterable[Coil], timeout: float = 0 - ) -> AsyncIterator[Coil]: - for coil in coils: - yield await read_coil(coil, timeout) - - mock_connection.read_coil = read_coil - mock_connection.read_coils = read_coils - # pylint: disable-next=import-outside-toplevel from homeassistant.components.nibe_heatpump import HeatPump get_coils_original = HeatPump.get_coils + get_coil_by_address_original = HeatPump.get_coil_by_address def get_coils(x): coils_data = get_coils_original(x) - return [coil for coil in coils_data if coil.address in coils] + return [coil for coil in coils_data if coil.address in mock_connection.coils] - with patch.object(HeatPump, "get_coils", new=get_coils): - yield coils + def get_coil_by_address(self, address): + coils_data = get_coil_by_address_original(self, address) + if coils_data.address not in mock_connection.coils: + raise CoilNotFoundException() + return coils_data + + with patch.object(HeatPump, "get_coils", new=get_coils), patch.object( + HeatPump, "get_coil_by_address", new=get_coil_by_address + ): + yield mock_connection.coils + + +@pytest.fixture(name="freezer_ticker") +async def fixture_freezer_ticker(hass: HomeAssistant, freezer: FrozenDateTimeFactory): + """Tick time and perform actions.""" + + async def ticker(delay, block=True): + freezer.tick(delay) + async_fire_time_changed(hass) + if block: + await hass.async_block_till_done() + + return ticker diff --git a/tests/components/nibe_heatpump/snapshots/test_climate.ambr b/tests/components/nibe_heatpump/snapshots/test_climate.ambr new file mode 100644 index 00000000000..f19fd69c47d --- /dev/null +++ b/tests/components/nibe_heatpump/snapshots/test_climate.ambr @@ -0,0 +1,517 @@ +# serializer version: 1 +# name: test_active_accessory[Model.F1155-s2-climate.climate_system_s2][initial] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S2', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 21.0, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s2', + 'last_changed': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_active_accessory[Model.F1155-s2-climate.climate_system_s2][unavailable (not supported)] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Climate System S2', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_step': 0.5, + }), + 'context': , + 'entity_id': 'climate.climate_system_s2', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_active_accessory[Model.F1155-s3-climate.climate_system_s3][initial] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S3', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 21.0, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s3', + 'last_changed': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_active_accessory[Model.F1155-s3-climate.climate_system_s3][unavailable (not supported)] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Climate System S3', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_step': 0.5, + }), + 'context': , + 'entity_id': 'climate.climate_system_s3', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_active_accessory[Model.S320-s2-climate.climate_system_21][initial] + None +# --- +# name: test_active_accessory[Model.S320-s2-climate.climate_system_s1][initial] + None +# --- +# name: test_basic[Model.F1155-s2-climate.climate_system_s2][cooling] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S2', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 21.0, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s2', + 'last_changed': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_basic[Model.F1155-s2-climate.climate_system_s2][heating (auto)] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S2', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s2', + 'last_changed': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_basic[Model.F1155-s2-climate.climate_system_s2][heating (only)] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S2', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'target_temp_step': 0.5, + 'temperature': 21.0, + }), + 'context': , + 'entity_id': 'climate.climate_system_s2', + 'last_changed': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_basic[Model.F1155-s2-climate.climate_system_s2][heating] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S2', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 21.0, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s2', + 'last_changed': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_basic[Model.F1155-s2-climate.climate_system_s2][idle (mixing valve)] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S2', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 21.0, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s2', + 'last_changed': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_basic[Model.F1155-s2-climate.climate_system_s2][initial] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S2', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 21.0, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s2', + 'last_changed': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_basic[Model.F1155-s2-climate.climate_system_s2][off (auto)] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S2', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s2', + 'last_changed': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_basic[Model.F1155-s2-climate.climate_system_s2][unavailable] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S2', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s2', + 'last_changed': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_basic[Model.S320-s1-climate.climate_system_s1][cooling] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 21.0, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s1', + 'last_changed': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_basic[Model.S320-s1-climate.climate_system_s1][heating (auto)] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s1', + 'last_changed': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_basic[Model.S320-s1-climate.climate_system_s1][heating (only)] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'target_temp_step': 0.5, + 'temperature': 21.0, + }), + 'context': , + 'entity_id': 'climate.climate_system_s1', + 'last_changed': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_basic[Model.S320-s1-climate.climate_system_s1][heating] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 21.0, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s1', + 'last_changed': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_basic[Model.S320-s1-climate.climate_system_s1][idle (mixing valve)] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 21.0, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s1', + 'last_changed': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_basic[Model.S320-s1-climate.climate_system_s1][initial] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 21.0, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s1', + 'last_changed': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_basic[Model.S320-s1-climate.climate_system_s1][off (auto)] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s1', + 'last_changed': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_basic[Model.S320-s1-climate.climate_system_s1][unavailable] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s1', + 'last_changed': , + 'last_updated': , + 'state': 'auto', + }) +# --- diff --git a/tests/components/nibe_heatpump/snapshots/test_coordinator.ambr b/tests/components/nibe_heatpump/snapshots/test_coordinator.ambr new file mode 100644 index 00000000000..98e62a833a8 --- /dev/null +++ b/tests/components/nibe_heatpump/snapshots/test_coordinator.ambr @@ -0,0 +1,133 @@ +# serializer version: 1 +# name: test_invalid_coil[Sensor is available] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'S320 Heating offset climate system 1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.heating_offset_climate_system_1_40031', + 'last_changed': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_invalid_coil[Sensor is not available] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'S320 Heating offset climate system 1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.heating_offset_climate_system_1_40031', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_partial_refresh[1. Sensor is available] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'S320 Heating offset climate system 1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.heating_offset_climate_system_1_40031', + 'last_changed': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_partial_refresh[2. Sensor is not available] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'S320 Min supply climate system 1', + 'max': 80.0, + 'min': 5.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'number.min_supply_climate_system_1_40035', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_partial_refresh[3. Sensor is available] + None +# --- +# name: test_pushed_update[1. initial values] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'S320 Heating offset climate system 1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.heating_offset_climate_system_1_40031', + 'last_changed': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_pushed_update[2. pushed values] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'S320 Heating offset climate system 1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.heating_offset_climate_system_1_40031', + 'last_changed': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_pushed_update[3. seeded values] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'S320 Heating offset climate system 1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.heating_offset_climate_system_1_40031', + 'last_changed': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_pushed_update[4. final values] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'S320 Heating offset climate system 1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.heating_offset_climate_system_1_40031', + 'last_changed': , + 'last_updated': , + 'state': '30.0', + }) +# --- diff --git a/tests/components/nibe_heatpump/test_button.py b/tests/components/nibe_heatpump/test_button.py index 755827fa128..d150d3f2d38 100644 --- a/tests/components/nibe_heatpump/test_button.py +++ b/tests/components/nibe_heatpump/test_button.py @@ -2,7 +2,6 @@ from typing import Any from unittest.mock import AsyncMock, patch -from freezegun.api import FrozenDateTimeFactory from nibe.coil import CoilData from nibe.coil_groups import UNIT_COILGROUPS from nibe.heatpump import Model @@ -19,8 +18,6 @@ from homeassistant.core import HomeAssistant from . import async_add_model -from tests.common import async_fire_time_changed - @pytest.fixture(autouse=True) async def fixture_single_platform(): @@ -42,7 +39,7 @@ async def test_reset_button( model: Model, entity_id: str, coils: dict[int, Any], - freezer: FrozenDateTimeFactory, + freezer_ticker: Any, ): """Test reset button.""" @@ -61,9 +58,7 @@ async def test_reset_button( # Signal alarm coils[unit.alarm] = 100 - freezer.tick(60) - async_fire_time_changed(hass) - await hass.async_block_till_done() + await freezer_ticker(60) state = hass.states.get(entity_id) assert state diff --git a/tests/components/nibe_heatpump/test_climate.py b/tests/components/nibe_heatpump/test_climate.py new file mode 100644 index 00000000000..2b3fe5d8c0e --- /dev/null +++ b/tests/components/nibe_heatpump/test_climate.py @@ -0,0 +1,317 @@ +"""Test the Nibe Heat Pump config flow.""" +from typing import Any +from unittest.mock import call, patch + +from nibe.coil import CoilData +from nibe.coil_groups import ( + CLIMATE_COILGROUPS, + UNIT_COILGROUPS, + ClimateCoilGroup, + UnitCoilGroup, +) +from nibe.heatpump import Model +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, + DOMAIN as PLATFORM_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant + +from . import MockConnection, async_add_model + + +@pytest.fixture(autouse=True) +async def fixture_single_platform(): + """Only allow this platform to load.""" + with patch("homeassistant.components.nibe_heatpump.PLATFORMS", [Platform.CLIMATE]): + yield + + +def _setup_climate_group( + coils: dict[int, Any], model: Model, climate_id: str +) -> tuple[ClimateCoilGroup, UnitCoilGroup]: + """Initialize coils for a climate group, with some default values.""" + climate = CLIMATE_COILGROUPS[model.series][climate_id] + unit = UNIT_COILGROUPS[model.series]["main"] + + if climate.active_accessory is not None: + coils[climate.active_accessory] = "ON" + coils[climate.current] = 20.5 + coils[climate.setpoint_heat] = 21.0 + coils[climate.setpoint_cool] = 30.0 + coils[climate.mixing_valve_state] = 20 + coils[climate.use_room_sensor] = "ON" + coils[unit.prio] = "OFF" + coils[unit.cooling_with_room_sensor] = "ON" + + return climate, unit + + +@pytest.mark.parametrize( + ("model", "climate_id", "entity_id"), + [ + (Model.S320, "s1", "climate.climate_system_s1"), + (Model.F1155, "s2", "climate.climate_system_s2"), + ], +) +async def test_basic( + hass: HomeAssistant, + mock_connection: MockConnection, + model: Model, + climate_id: str, + entity_id: str, + coils: dict[int, Any], + entity_registry_enabled_by_default: None, + snapshot: SnapshotAssertion, +) -> None: + """Test setting of value.""" + climate, unit = _setup_climate_group(coils, model, climate_id) + + await async_add_model(hass, model) + + assert hass.states.get(entity_id) == snapshot(name="initial") + + mock_connection.mock_coil_update(unit.prio, "COOLING") + assert hass.states.get(entity_id) == snapshot(name="cooling") + + mock_connection.mock_coil_update(unit.prio, "HEAT") + assert hass.states.get(entity_id) == snapshot(name="heating") + + mock_connection.mock_coil_update(climate.mixing_valve_state, 30) + assert hass.states.get(entity_id) == snapshot(name="idle (mixing valve)") + + mock_connection.mock_coil_update(climate.mixing_valve_state, 20) + mock_connection.mock_coil_update(unit.cooling_with_room_sensor, "OFF") + assert hass.states.get(entity_id) == snapshot(name="heating (only)") + + mock_connection.mock_coil_update(climate.use_room_sensor, "OFF") + assert hass.states.get(entity_id) == snapshot(name="heating (auto)") + + mock_connection.mock_coil_update(unit.prio, None) + assert hass.states.get(entity_id) == snapshot(name="off (auto)") + + coils.clear() + assert hass.states.get(entity_id) == snapshot(name="unavailable") + + +@pytest.mark.parametrize( + ("model", "climate_id", "entity_id"), + [ + (Model.F1155, "s2", "climate.climate_system_s2"), + (Model.F1155, "s3", "climate.climate_system_s3"), + ], +) +async def test_active_accessory( + hass: HomeAssistant, + mock_connection: MockConnection, + model: Model, + climate_id: str, + entity_id: str, + coils: dict[int, Any], + entity_registry_enabled_by_default: None, + snapshot: SnapshotAssertion, +) -> None: + """Test climate groups that can be deactivated by configuration.""" + climate, unit = _setup_climate_group(coils, model, climate_id) + + await async_add_model(hass, model) + + assert hass.states.get(entity_id) == snapshot(name="initial") + + mock_connection.mock_coil_update(climate.active_accessory, "OFF") + assert hass.states.get(entity_id) == snapshot(name="unavailable (not supported)") + + +@pytest.mark.parametrize( + ("model", "climate_id", "entity_id"), + [ + (Model.S320, "s1", "climate.climate_system_s1"), + (Model.F1155, "s2", "climate.climate_system_s2"), + ], +) +async def test_set_temperature( + hass: HomeAssistant, + mock_connection: MockConnection, + model: Model, + climate_id: str, + entity_id: str, + coils: dict[int, Any], + entity_registry_enabled_by_default: None, + snapshot: SnapshotAssertion, +) -> None: + """Test setting temperature.""" + climate, _ = _setup_climate_group(coils, model, climate_id) + + await async_add_model(hass, model) + + coil_setpoint_heat = mock_connection.heatpump.get_coil_by_address( + climate.setpoint_heat + ) + coil_setpoint_cool = mock_connection.heatpump.get_coil_by_address( + climate.setpoint_cool + ) + + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TEMPERATURE: 22, + ATTR_HVAC_MODE: HVACMode.HEAT, + }, + blocking=True, + ) + await hass.async_block_till_done() + + assert mock_connection.write_coil.mock_calls == [ + call(CoilData(coil_setpoint_heat, 22)) + ] + mock_connection.write_coil.reset_mock() + + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TEMPERATURE: 22, + ATTR_HVAC_MODE: HVACMode.COOL, + }, + blocking=True, + ) + await hass.async_block_till_done() + + assert mock_connection.write_coil.mock_calls == [ + call(CoilData(coil_setpoint_cool, 22)) + ] + mock_connection.write_coil.reset_mock() + + with pytest.raises(ValueError): + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TEMPERATURE: 22, + }, + blocking=True, + ) + + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TARGET_TEMP_HIGH: 30, + ATTR_TARGET_TEMP_LOW: 22, + }, + blocking=True, + ) + await hass.async_block_till_done() + + assert mock_connection.write_coil.mock_calls == [ + call(CoilData(coil_setpoint_heat, 22)), + call(CoilData(coil_setpoint_cool, 30)), + ] + + mock_connection.write_coil.reset_mock() + + +@pytest.mark.parametrize( + ("hvac_mode", "cooling_with_room_sensor", "use_room_sensor"), + [ + (HVACMode.HEAT_COOL, "ON", "ON"), + (HVACMode.HEAT, "OFF", "ON"), + (HVACMode.AUTO, "OFF", "OFF"), + ], +) +@pytest.mark.parametrize( + ("model", "climate_id", "entity_id"), + [ + (Model.S320, "s1", "climate.climate_system_s1"), + (Model.F1155, "s2", "climate.climate_system_s2"), + ], +) +async def test_set_hvac_mode( + hass: HomeAssistant, + mock_connection: MockConnection, + model: Model, + climate_id: str, + entity_id: str, + cooling_with_room_sensor: str, + use_room_sensor: str, + hvac_mode: HVACMode, + coils: dict[int, Any], + entity_registry_enabled_by_default: None, +) -> None: + """Test setting a hvac mode.""" + climate, unit = _setup_climate_group(coils, model, climate_id) + + await async_add_model(hass, model) + + coil_use_room_sensor = mock_connection.heatpump.get_coil_by_address( + climate.use_room_sensor + ) + coil_cooling_with_room_sensor = mock_connection.heatpump.get_coil_by_address( + unit.cooling_with_room_sensor + ) + + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_HVAC_MODE: hvac_mode, + }, + blocking=True, + ) + await hass.async_block_till_done() + + assert mock_connection.write_coil.mock_calls == [ + call(CoilData(coil_cooling_with_room_sensor, cooling_with_room_sensor)), + call(CoilData(coil_use_room_sensor, use_room_sensor)), + ] + + +@pytest.mark.parametrize( + ("model", "climate_id", "entity_id"), + [ + (Model.S320, "s1", "climate.climate_system_s1"), + (Model.F1155, "s2", "climate.climate_system_s2"), + ], +) +async def test_set_invalid_hvac_mode( + hass: HomeAssistant, + mock_connection: MockConnection, + model: Model, + climate_id: str, + entity_id: str, + coils: dict[int, Any], + entity_registry_enabled_by_default: None, +) -> None: + """Test setting an invalid hvac mode.""" + _setup_climate_group(coils, model, climate_id) + + await async_add_model(hass, model) + + with pytest.raises(ValueError): + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_HVAC_MODE: HVACMode.DRY, + }, + blocking=True, + ) + await hass.async_block_till_done() + + assert mock_connection.write_coil.mock_calls == [] diff --git a/tests/components/nibe_heatpump/test_config_flow.py b/tests/components/nibe_heatpump/test_config_flow.py index 22dca1fa2f3..9b03159af2f 100644 --- a/tests/components/nibe_heatpump/test_config_flow.py +++ b/tests/components/nibe_heatpump/test_config_flow.py @@ -1,7 +1,7 @@ """Test the Nibe Heat Pump config flow.""" -from unittest.mock import Mock +from typing import Any +from unittest.mock import AsyncMock, Mock -from nibe.coil import Coil from nibe.exceptions import ( AddressInUseException, CoilNotFoundException, @@ -54,16 +54,12 @@ async def _get_connection_form( async def test_nibegw_form( - hass: HomeAssistant, mock_connection: Mock, mock_setup_entry: Mock + hass: HomeAssistant, coils: dict[int, Any], mock_setup_entry: Mock ) -> None: """Test we get the form.""" result = await _get_connection_form(hass, "nibegw") - coil_wordswap = Coil( - 48852, "modbus40-word-swap-48852", "Modbus40 Word Swap", "u8", min=0, max=1 - ) - coil_wordswap.value = "ON" - mock_connection.read_coil.return_value = coil_wordswap + coils[48852] = 1 result2 = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_FLOW_NIBEGW_USERDATA @@ -85,16 +81,12 @@ async def test_nibegw_form( async def test_modbus_form( - hass: HomeAssistant, mock_connection: Mock, mock_setup_entry: Mock + hass: HomeAssistant, coils: dict[int, Any], mock_setup_entry: Mock ) -> None: """Test we get the form.""" result = await _get_connection_form(hass, "modbus") - coil = Coil( - 40022, "reset-alarm-40022", "Reset Alarm", "u8", min=0, max=1, write=True - ) - coil.value = "ON" - mock_connection.read_coil.return_value = coil + coils[40022] = 1 result2 = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_FLOW_MODBUS_USERDATA @@ -113,12 +105,12 @@ async def test_modbus_form( async def test_modbus_invalid_url( - hass: HomeAssistant, mock_connection_constructor: Mock + hass: HomeAssistant, mock_connection_construct: Mock ) -> None: """Test we handle invalid auth.""" result = await _get_connection_form(hass, "modbus") - mock_connection_constructor.side_effect = ValueError() + mock_connection_construct.side_effect = ValueError() result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {**MOCK_FLOW_MODBUS_USERDATA, "modbus_url": "invalid://url"} ) @@ -131,6 +123,7 @@ async def test_nibegw_address_inuse(hass: HomeAssistant, mock_connection: Mock) """Test we handle invalid auth.""" result = await _get_connection_form(hass, "nibegw") + mock_connection.start = AsyncMock() mock_connection.start.side_effect = AddressInUseException() result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/nibe_heatpump/test_coordinator.py b/tests/components/nibe_heatpump/test_coordinator.py new file mode 100644 index 00000000000..474802541f2 --- /dev/null +++ b/tests/components/nibe_heatpump/test_coordinator.py @@ -0,0 +1,130 @@ +"""Test the Nibe Heat Pump config flow.""" +import asyncio +from typing import Any +from unittest.mock import patch + +from nibe.coil import Coil, CoilData +from nibe.heatpump import Model +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from . import MockConnection, async_add_model + + +@pytest.fixture(autouse=True) +async def fixture_single_platform(): + """Only allow this platform to load.""" + with patch("homeassistant.components.nibe_heatpump.PLATFORMS", [Platform.NUMBER]): + yield + + +async def test_partial_refresh( + hass: HomeAssistant, + coils: dict[int, Any], + entity_registry_enabled_by_default: None, + snapshot: SnapshotAssertion, +) -> None: + """Test that coordinator can handle partial fields.""" + coils[40031] = 10 + coils[40035] = None + coils[40039] = 10 + + await async_add_model(hass, Model.S320) + + data = hass.states.get("number.heating_offset_climate_system_1_40031") + assert data == snapshot(name="1. Sensor is available") + + data = hass.states.get("number.min_supply_climate_system_1_40035") + assert data == snapshot(name="2. Sensor is not available") + + data = hass.states.get("number.max_supply_climate_system_1_40035") + assert data == snapshot(name="3. Sensor is available") + + +async def test_invalid_coil( + hass: HomeAssistant, + coils: dict[int, Any], + entity_registry_enabled_by_default: None, + snapshot: SnapshotAssertion, + freezer_ticker: Any, +) -> None: + """That update coordinator correctly marks entities unavailable with missing coils.""" + entity_id = "number.heating_offset_climate_system_1_40031" + coil_id = 40031 + + coils[coil_id] = 10 + await async_add_model(hass, Model.S320) + + assert hass.states.get(entity_id) == snapshot(name="Sensor is available") + + coils.pop(coil_id) + await freezer_ticker(60) + + assert hass.states.get(entity_id) == snapshot(name="Sensor is not available") + + +async def test_pushed_update( + hass: HomeAssistant, + coils: dict[int, Any], + entity_registry_enabled_by_default: None, + snapshot: SnapshotAssertion, + mock_connection: MockConnection, + freezer_ticker: Any, +) -> None: + """Test out of band pushed value, update directly and seed the next update.""" + entity_id = "number.heating_offset_climate_system_1_40031" + coil_id = 40031 + + coils[coil_id] = 10 + await async_add_model(hass, Model.S320) + + assert hass.states.get(entity_id) == snapshot(name="1. initial values") + + mock_connection.mock_coil_update(coil_id, 20) + assert hass.states.get(entity_id) == snapshot(name="2. pushed values") + + coils[coil_id] = 30 + await freezer_ticker(60) + + assert hass.states.get(entity_id) == snapshot(name="3. seeded values") + + await freezer_ticker(60) + + assert hass.states.get(entity_id) == snapshot(name="4. final values") + + +async def test_shutdown( + hass: HomeAssistant, + coils: dict[int, Any], + entity_registry_enabled_by_default: None, + mock_connection: MockConnection, + freezer_ticker: Any, +) -> None: + """Check that shutdown, cancel a long running update.""" + coils[40031] = 10 + + entry = await async_add_model(hass, Model.S320) + mock_connection.start.assert_called_once() + + done = asyncio.Event() + hang = asyncio.Event() + + async def _read_coil_hang(coil: Coil, timeout: float = 0) -> CoilData: + try: + hang.set() + await done.wait() # infinite wait + except asyncio.CancelledError: + done.set() + + mock_connection.read_coil = _read_coil_hang + + await freezer_ticker(60, block=False) + await hang.wait() + + await hass.config_entries.async_unload(entry.entry_id) + + assert done.is_set() + mock_connection.stop.assert_called_once() diff --git a/tests/components/nut/test_sensor.py b/tests/components/nut/test_sensor.py index 4ec1e3c47ca..014e683b30c 100644 --- a/tests/components/nut/test_sensor.py +++ b/tests/components/nut/test_sensor.py @@ -1,6 +1,8 @@ """The sensor tests for the nut platform.""" from unittest.mock import patch +import pytest + from homeassistant.components.nut.const import DOMAIN from homeassistant.const import ( CONF_HOST, @@ -17,37 +19,25 @@ from .util import _get_mock_pynutclient, async_init_integration from tests.common import MockConfigEntry -async def test_pr3000rt2u(hass: HomeAssistant) -> None: - """Test creation of PR3000RT2U sensors.""" +@pytest.mark.parametrize( + "model", + [ + "CP1350C", + "5E650I", + "5E850I", + "CP1500PFCLCD", + "DL650ELCD", + "EATON5P1550", + "blazer_usb", + ], +) +async def test_devices( + hass: HomeAssistant, entity_registry: er.EntityRegistry, model: str +) -> None: + """Test creation of device sensors.""" - await async_init_integration(hass, "PR3000RT2U") - registry = er.async_get(hass) - entry = registry.async_get("sensor.ups1_battery_charge") - assert entry - assert entry.unique_id == "CPS_PR3000RT2U_PYVJO2000034_battery.charge" - - state = hass.states.get("sensor.ups1_battery_charge") - assert state.state == "100" - - expected_attributes = { - "device_class": "battery", - "friendly_name": "Ups1 Battery charge", - "unit_of_measurement": PERCENTAGE, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all( - state.attributes[key] == attr for key, attr in expected_attributes.items() - ) - - -async def test_cp1350c(hass: HomeAssistant) -> None: - """Test creation of CP1350C sensors.""" - - config_entry = await async_init_integration(hass, "CP1350C") - - registry = er.async_get(hass) - entry = registry.async_get("sensor.ups1_battery_charge") + config_entry = await async_init_integration(hass, model) + entry = entity_registry.async_get("sensor.ups1_battery_charge") assert entry assert entry.unique_id == f"{config_entry.entry_id}_battery.charge" @@ -66,161 +56,25 @@ async def test_cp1350c(hass: HomeAssistant) -> None: ) -async def test_5e850i(hass: HomeAssistant) -> None: - """Test creation of 5E850I sensors.""" +@pytest.mark.parametrize( + ("model", "unique_id"), + [ + ("PR3000RT2U", "CPS_PR3000RT2U_PYVJO2000034_battery.charge"), + ( + "BACKUPSES600M1", + "American Power Conversion_Back-UPS ES 600M1_4B1713P32195 _battery.charge", + ), + ], +) +async def test_devices_with_unique_ids( + hass: HomeAssistant, entity_registry: er.EntityRegistry, model: str, unique_id: str +) -> None: + """Test creation of device sensors with unique ids.""" - config_entry = await async_init_integration(hass, "5E850I") - registry = er.async_get(hass) - entry = registry.async_get("sensor.ups1_battery_charge") + await async_init_integration(hass, model) + entry = entity_registry.async_get("sensor.ups1_battery_charge") assert entry - assert entry.unique_id == f"{config_entry.entry_id}_battery.charge" - - state = hass.states.get("sensor.ups1_battery_charge") - assert state.state == "100" - - expected_attributes = { - "device_class": "battery", - "friendly_name": "Ups1 Battery charge", - "unit_of_measurement": PERCENTAGE, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all( - state.attributes[key] == attr for key, attr in expected_attributes.items() - ) - - -async def test_5e650i(hass: HomeAssistant) -> None: - """Test creation of 5E650I sensors.""" - - config_entry = await async_init_integration(hass, "5E650I") - registry = er.async_get(hass) - entry = registry.async_get("sensor.ups1_battery_charge") - assert entry - assert entry.unique_id == f"{config_entry.entry_id}_battery.charge" - - state = hass.states.get("sensor.ups1_battery_charge") - assert state.state == "100" - - expected_attributes = { - "device_class": "battery", - "friendly_name": "Ups1 Battery charge", - "unit_of_measurement": PERCENTAGE, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all( - state.attributes[key] == attr for key, attr in expected_attributes.items() - ) - - -async def test_backupsses600m1(hass: HomeAssistant) -> None: - """Test creation of BACKUPSES600M1 sensors.""" - - await async_init_integration(hass, "BACKUPSES600M1") - registry = er.async_get(hass) - entry = registry.async_get("sensor.ups1_battery_charge") - assert entry - assert ( - entry.unique_id - == "American Power Conversion_Back-UPS ES 600M1_4B1713P32195 _battery.charge" - ) - - state = hass.states.get("sensor.ups1_battery_charge") - assert state.state == "100" - - expected_attributes = { - "device_class": "battery", - "friendly_name": "Ups1 Battery charge", - "unit_of_measurement": PERCENTAGE, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all( - state.attributes[key] == attr for key, attr in expected_attributes.items() - ) - - -async def test_cp1500pfclcd(hass: HomeAssistant) -> None: - """Test creation of CP1500PFCLCD sensors.""" - - config_entry = await async_init_integration(hass, "CP1500PFCLCD") - registry = er.async_get(hass) - entry = registry.async_get("sensor.ups1_battery_charge") - assert entry - assert entry.unique_id == f"{config_entry.entry_id}_battery.charge" - - state = hass.states.get("sensor.ups1_battery_charge") - assert state.state == "100" - - expected_attributes = { - "device_class": "battery", - "friendly_name": "Ups1 Battery charge", - "unit_of_measurement": PERCENTAGE, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all( - state.attributes[key] == attr for key, attr in expected_attributes.items() - ) - - -async def test_dl650elcd(hass: HomeAssistant) -> None: - """Test creation of DL650ELCD sensors.""" - - config_entry = await async_init_integration(hass, "DL650ELCD") - registry = er.async_get(hass) - entry = registry.async_get("sensor.ups1_battery_charge") - assert entry - assert entry.unique_id == f"{config_entry.entry_id}_battery.charge" - - state = hass.states.get("sensor.ups1_battery_charge") - assert state.state == "100" - - expected_attributes = { - "device_class": "battery", - "friendly_name": "Ups1 Battery charge", - "unit_of_measurement": PERCENTAGE, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all( - state.attributes[key] == attr for key, attr in expected_attributes.items() - ) - - -async def test_eaton5p1550(hass: HomeAssistant) -> None: - """Test creation of EATON5P1550 sensors.""" - - config_entry = await async_init_integration(hass, "EATON5P1550") - registry = er.async_get(hass) - entry = registry.async_get("sensor.ups1_battery_charge") - assert entry - assert entry.unique_id == f"{config_entry.entry_id}_battery.charge" - - state = hass.states.get("sensor.ups1_battery_charge") - assert state.state == "100" - - expected_attributes = { - "device_class": "battery", - "friendly_name": "Ups1 Battery charge", - "unit_of_measurement": PERCENTAGE, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all( - state.attributes[key] == attr for key, attr in expected_attributes.items() - ) - - -async def test_blazer_usb(hass: HomeAssistant) -> None: - """Test creation of blazer_usb sensors.""" - - config_entry = await async_init_integration(hass, "blazer_usb") - registry = er.async_get(hass) - entry = registry.async_get("sensor.ups1_battery_charge") - assert entry - assert entry.unique_id == f"{config_entry.entry_id}_battery.charge" + assert entry.unique_id == unique_id state = hass.states.get("sensor.ups1_battery_charge") assert state.state == "100" diff --git a/tests/components/nws/snapshots/test_weather.ambr b/tests/components/nws/snapshots/test_weather.ambr index 0dddca954be..0db2311085c 100644 --- a/tests/components/nws/snapshots/test_weather.ambr +++ b/tests/components/nws/snapshots/test_weather.ambr @@ -103,6 +103,309 @@ ]), }) # --- +# name: test_forecast_service[forecast] + dict({ + 'weather.abc_daynight': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'is_daytime': False, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast].1 + dict({ + 'weather.abc_daynight': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast].2 + dict({ + 'weather.abc_daynight': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'is_daytime': False, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast].3 + dict({ + 'weather.abc_daynight': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast].4 + dict({ + 'weather.abc_daynight': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast].5 + dict({ + 'weather.abc_daynight': dict({ + 'forecast': list([ + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecast] + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'is_daytime': False, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecast].1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecast].2 + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'is_daytime': False, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecast].3 + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecast].4 + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecast].5 + dict({ + 'forecast': list([ + ]), + }) +# --- +# name: test_forecast_service[get_forecasts] + dict({ + 'weather.abc_daynight': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'is_daytime': False, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecasts].1 + dict({ + 'weather.abc_daynight': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecasts].2 + dict({ + 'weather.abc_daynight': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'is_daytime': False, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecasts].3 + dict({ + 'weather.abc_daynight': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecasts].4 + dict({ + 'weather.abc_daynight': dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecasts].5 + dict({ + 'weather.abc_daynight': dict({ + 'forecast': list([ + ]), + }), + }) +# --- # name: test_forecast_subscription[hourly-weather.abc_daynight] list([ dict({ diff --git a/tests/components/nws/test_weather.py b/tests/components/nws/test_weather.py index 54069eec02c..c7478be7c07 100644 --- a/tests/components/nws/test_weather.py +++ b/tests/components/nws/test_weather.py @@ -13,7 +13,8 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_FORECAST, DOMAIN as WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + LEGACY_SERVICE_GET_FORECAST, + SERVICE_GET_FORECASTS, ) from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -400,12 +401,20 @@ async def test_legacy_config_entry(hass: HomeAssistant, no_sensor) -> None: assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 2 +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) async def test_forecast_service( hass: HomeAssistant, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, mock_simple_nws, no_sensor, + service: str, ) -> None: """Test multiple forecast.""" instance = mock_simple_nws.return_value @@ -425,7 +434,7 @@ async def test_forecast_service( for forecast_type in ("twice_daily", "hourly"): response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.abc_daynight", "type": forecast_type, @@ -433,7 +442,6 @@ async def test_forecast_service( blocking=True, return_response=True, ) - assert response["forecast"] != [] assert response == snapshot # Calling the services should use cached data @@ -453,7 +461,7 @@ async def test_forecast_service( for forecast_type in ("twice_daily", "hourly"): response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.abc_daynight", "type": forecast_type, @@ -461,7 +469,6 @@ async def test_forecast_service( blocking=True, return_response=True, ) - assert response["forecast"] != [] assert response == snapshot # Calling the services should update the hourly forecast @@ -477,7 +484,7 @@ async def test_forecast_service( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.abc_daynight", "type": "hourly", @@ -485,7 +492,6 @@ async def test_forecast_service( blocking=True, return_response=True, ) - assert response["forecast"] != [] assert response == snapshot # after additional 35 minutes data caching expires, data is no longer shown @@ -495,7 +501,7 @@ async def test_forecast_service( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.abc_daynight", "type": "hourly", @@ -503,7 +509,7 @@ async def test_forecast_service( blocking=True, return_response=True, ) - assert response["forecast"] == [] + assert response == snapshot @pytest.mark.parametrize( diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index c888381230c..47568a7d760 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -101,7 +101,8 @@ async def mock_supervisor_fixture(hass, aioclient_mock): "homeassistant.components.hassio.HassIO.get_ingress_panels", return_value={"panels": {}}, ), patch.dict( - os.environ, {"SUPERVISOR_TOKEN": "123456"} + os.environ, + {"SUPERVISOR_TOKEN": "123456"}, ): yield diff --git a/tests/components/open_meteo/conftest.py b/tests/components/open_meteo/conftest.py index cb950dcc442..76bb3039a2f 100644 --- a/tests/components/open_meteo/conftest.py +++ b/tests/components/open_meteo/conftest.py @@ -40,7 +40,7 @@ def mock_open_meteo(request: pytest.FixtureRequest) -> Generator[None, MagicMock if hasattr(request, "param") and request.param: fixture = request.param - forecast = Forecast.parse_raw(load_fixture(fixture, DOMAIN)) + forecast = Forecast.from_json(load_fixture(fixture, DOMAIN)) with patch( "homeassistant.components.open_meteo.OpenMeteo", autospec=True ) as open_meteo_mock: diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index 9f00290600e..40f2eb33f08 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -25,7 +25,7 @@ def mock_config_entry(hass): async def mock_init_component(hass, mock_config_entry): """Initialize integration.""" with patch( - "openai.Engine.list", + "openai.Model.list", ): assert await async_setup_component(hass, "openai_conversation", {}) await hass.async_block_till_done() diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index 471be8035b6..43dfc26ca82 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -32,7 +32,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result["errors"] is None with patch( - "homeassistant.components.openai_conversation.config_flow.openai.Engine.list", + "homeassistant.components.openai_conversation.config_flow.openai.Model.list", ), patch( "homeassistant.components.openai_conversation.async_setup_entry", return_value=True, @@ -88,7 +88,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non ) with patch( - "homeassistant.components.openai_conversation.config_flow.openai.Engine.list", + "homeassistant.components.openai_conversation.config_flow.openai.Model.list", side_effect=side_effect, ): result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 1b145d9d545..61fe33e5469 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -140,7 +140,7 @@ async def test_template_error( }, ) with patch( - "openai.Engine.list", + "openai.Model.list", ), patch("openai.ChatCompletion.acreate"): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/opentherm_gw/test_config_flow.py b/tests/components/opentherm_gw/test_config_flow.py index 0f2c15a5e4a..ef1ac166f1e 100644 --- a/tests/components/opentherm_gw/test_config_flow.py +++ b/tests/components/opentherm_gw/test_config_flow.py @@ -210,9 +210,11 @@ async def test_options_migration(hass: HomeAssistant) -> None: "homeassistant.components.opentherm_gw.OpenThermGatewayDevice.connect_and_subscribe", return_value=True, ), patch( - "homeassistant.components.opentherm_gw.async_setup", return_value=True + "homeassistant.components.opentherm_gw.async_setup", + return_value=True, ), patch( - "pyotgw.status.StatusManager._process_updates", return_value=None + "pyotgw.status.StatusManager._process_updates", + return_value=None, ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/otbr/test_util.py b/tests/components/otbr/test_util.py index 171a607d200..941c80a52da 100644 --- a/tests/components/otbr/test_util.py +++ b/tests/components/otbr/test_util.py @@ -73,7 +73,7 @@ async def test_factory_reset_error_1( ) as factory_reset_mock, patch( "python_otbr_api.OTBR.delete_active_dataset" ) as delete_active_dataset_mock, pytest.raises( - HomeAssistantError + HomeAssistantError, ): await data.factory_reset() @@ -94,7 +94,7 @@ async def test_factory_reset_error_2( "python_otbr_api.OTBR.delete_active_dataset", side_effect=python_otbr_api.OTBRError, ) as delete_active_dataset_mock, pytest.raises( - HomeAssistantError + HomeAssistantError, ): await data.factory_reset() diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index cba046a2a9d..8288e7e9f70 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -189,7 +189,7 @@ async def test_create_network_fails_3( ), patch( "python_otbr_api.OTBR.create_active_dataset", ), patch( - "python_otbr_api.OTBR.factory_reset" + "python_otbr_api.OTBR.factory_reset", ): await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() @@ -211,7 +211,7 @@ async def test_create_network_fails_4( "python_otbr_api.OTBR.get_active_dataset_tlvs", side_effect=python_otbr_api.OTBRError, ), patch( - "python_otbr_api.OTBR.factory_reset" + "python_otbr_api.OTBR.factory_reset", ): await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() diff --git a/tests/components/ourgroceries/__init__.py b/tests/components/ourgroceries/__init__.py new file mode 100644 index 00000000000..67fcb439908 --- /dev/null +++ b/tests/components/ourgroceries/__init__.py @@ -0,0 +1,6 @@ +"""Tests for the OurGroceries integration.""" + + +def items_to_shopping_list(items: list) -> dict[dict[list]]: + """Convert a list of items into a shopping list.""" + return {"list": {"items": items}} diff --git a/tests/components/ourgroceries/conftest.py b/tests/components/ourgroceries/conftest.py new file mode 100644 index 00000000000..7f113da2633 --- /dev/null +++ b/tests/components/ourgroceries/conftest.py @@ -0,0 +1,68 @@ +"""Common fixtures for the OurGroceries tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.ourgroceries import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import items_to_shopping_list + +from tests.common import MockConfigEntry + +USERNAME = "test-username" +PASSWORD = "test-password" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ourgroceries.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="ourgroceries_config_entry") +def mock_ourgroceries_config_entry() -> MockConfigEntry: + """Mock ourgroceries configuration.""" + return MockConfigEntry( + domain=DOMAIN, data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + ) + + +@pytest.fixture(name="items") +def mock_items() -> dict: + """Mock a collection of shopping list items.""" + return [] + + +@pytest.fixture(name="ourgroceries") +def mock_ourgroceries(items: list[dict]) -> AsyncMock: + """Mock the OurGroceries api.""" + og = AsyncMock() + og.login.return_value = True + og.get_my_lists.return_value = { + "shoppingLists": [{"id": "test_list", "name": "Test List"}] + } + og.get_list_items.return_value = items_to_shopping_list(items) + return og + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + ourgroceries: AsyncMock, + ourgroceries_config_entry: MockConfigEntry, +) -> None: + """Mock setup of the ourgroceries integration.""" + ourgroceries_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.ourgroceries.OurGroceries", return_value=ourgroceries + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield diff --git a/tests/components/ourgroceries/test_config_flow.py b/tests/components/ourgroceries/test_config_flow.py new file mode 100644 index 00000000000..f9d274125c1 --- /dev/null +++ b/tests/components/ourgroceries/test_config_flow.py @@ -0,0 +1,96 @@ +"""Test the OurGroceries config flow.""" +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.ourgroceries.config_flow import ( + AsyncIOTimeoutError, + ClientError, + InvalidLoginException, +) +from homeassistant.components.ourgroceries.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.ourgroceries.config_flow.OurGroceries.login", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "test-username" + assert result2["data"] == { + "username": "test-username", + "password": "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (InvalidLoginException, "invalid_auth"), + (ClientError, "cannot_connect"), + (AsyncIOTimeoutError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_error( + hass: HomeAssistant, exception: Exception, error: str, mock_setup_entry: AsyncMock +) -> None: + """Test we handle form errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.ourgroceries.config_flow.OurGroceries.login", + side_effect=exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": error} + with patch( + "homeassistant.components.ourgroceries.config_flow.OurGroceries.login", + return_value=True, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "test-username" + assert result3["data"] == { + "username": "test-username", + "password": "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/ourgroceries/test_init.py b/tests/components/ourgroceries/test_init.py new file mode 100644 index 00000000000..ef96c5e811c --- /dev/null +++ b/tests/components/ourgroceries/test_init.py @@ -0,0 +1,55 @@ +"""Unit tests for the OurGroceries integration.""" +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.ourgroceries import ( + AsyncIOTimeoutError, + ClientError, + InvalidLoginException, +) +from homeassistant.components.ourgroceries.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload( + hass: HomeAssistant, + setup_integration: None, + ourgroceries_config_entry: MockConfigEntry | None, +) -> None: + """Test loading and unloading of the config entry.""" + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + assert ourgroceries_config_entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(ourgroceries_config_entry.entry_id) + assert ourgroceries_config_entry.state == ConfigEntryState.NOT_LOADED + + +@pytest.fixture +def login_with_error(exception, ourgroceries: AsyncMock): + """Fixture to simulate error on login.""" + ourgroceries.login.side_effect = (exception,) + + +@pytest.mark.parametrize( + ("exception", "status"), + [ + (InvalidLoginException, ConfigEntryState.SETUP_ERROR), + (ClientError, ConfigEntryState.SETUP_RETRY), + (AsyncIOTimeoutError, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_init_failure( + hass: HomeAssistant, + login_with_error, + setup_integration: None, + status: ConfigEntryState, + ourgroceries_config_entry: MockConfigEntry | None, +) -> None: + """Test an initialization error on integration load.""" + assert ourgroceries_config_entry.state == status diff --git a/tests/components/ourgroceries/test_todo.py b/tests/components/ourgroceries/test_todo.py new file mode 100644 index 00000000000..65bbff0e601 --- /dev/null +++ b/tests/components/ourgroceries/test_todo.py @@ -0,0 +1,243 @@ +"""Unit tests for the OurGroceries todo platform.""" +from asyncio import TimeoutError as AsyncIOTimeoutError +from unittest.mock import AsyncMock + +from aiohttp import ClientError +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.ourgroceries.coordinator import SCAN_INTERVAL +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_component import async_update_entity + +from . import items_to_shopping_list + +from tests.common import async_fire_time_changed + + +@pytest.mark.parametrize( + ("items", "expected_state"), + [ + ([], "0"), + ([{"id": "12345", "name": "Soda"}], "1"), + ([{"id": "12345", "name": "Soda", "crossedOffAt": 1699107501}], "0"), + ( + [ + {"id": "12345", "name": "Soda"}, + {"id": "54321", "name": "Milk"}, + ], + "2", + ), + ], +) +async def test_todo_item_state( + hass: HomeAssistant, + setup_integration: None, + expected_state: str, +) -> None: + """Test for a shopping list entity state.""" + + state = hass.states.get("todo.test_list") + assert state + assert state.state == expected_state + + +async def test_add_todo_list_item( + hass: HomeAssistant, + setup_integration: None, + ourgroceries: AsyncMock, +) -> None: + """Test for adding an item.""" + + state = hass.states.get("todo.test_list") + assert state + assert state.state == "0" + + ourgroceries.add_item_to_list = AsyncMock() + # Fake API response when state is refreshed after create + ourgroceries.get_list_items.return_value = items_to_shopping_list( + [{"id": "12345", "name": "Soda"}] + ) + + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "Soda"}, + target={"entity_id": "todo.test_list"}, + blocking=True, + ) + + args = ourgroceries.add_item_to_list.call_args + assert args + assert args.args == ("test_list", "Soda") + assert args.kwargs.get("auto_category") is True + + # Verify state is refreshed + state = hass.states.get("todo.test_list") + assert state + assert state.state == "1" + + +@pytest.mark.parametrize(("items"), [[{"id": "12345", "name": "Soda"}]]) +async def test_update_todo_item_status( + hass: HomeAssistant, + setup_integration: None, + ourgroceries: AsyncMock, +) -> None: + """Test for updating the completion status of an item.""" + + state = hass.states.get("todo.test_list") + assert state + assert state.state == "1" + + ourgroceries.toggle_item_crossed_off = AsyncMock() + + # Fake API response when state is refreshed after crossing off + ourgroceries.get_list_items.return_value = items_to_shopping_list( + [{"id": "12345", "name": "Soda", "crossedOffAt": 1699107501}] + ) + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": "12345", "status": "completed"}, + target={"entity_id": "todo.test_list"}, + blocking=True, + ) + assert ourgroceries.toggle_item_crossed_off.called + args = ourgroceries.toggle_item_crossed_off.call_args + assert args + assert args.args == ("test_list", "12345") + assert args.kwargs.get("cross_off") is True + + # Verify state is refreshed + state = hass.states.get("todo.test_list") + assert state + assert state.state == "0" + + # Fake API response when state is refreshed after reopen + ourgroceries.get_list_items.return_value = items_to_shopping_list( + [{"id": "12345", "name": "Soda"}] + ) + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": "12345", "status": "needs_action"}, + target={"entity_id": "todo.test_list"}, + blocking=True, + ) + assert ourgroceries.toggle_item_crossed_off.called + args = ourgroceries.toggle_item_crossed_off.call_args + assert args + assert args.args == ("test_list", "12345") + assert args.kwargs.get("cross_off") is False + + # Verify state is refreshed + state = hass.states.get("todo.test_list") + assert state + assert state.state == "1" + + +@pytest.mark.parametrize( + ("items"), [[{"id": "12345", "name": "Soda", "categoryId": "test_category"}]] +) +async def test_update_todo_item_summary( + hass: HomeAssistant, + setup_integration: None, + ourgroceries: AsyncMock, +) -> None: + """Test for updating an item summary.""" + + state = hass.states.get("todo.test_list") + assert state + assert state.state == "1" + + ourgroceries.change_item_on_list = AsyncMock() + + # Fake API response when state is refreshed update + ourgroceries.get_list_items.return_value = items_to_shopping_list( + [{"id": "12345", "name": "Milk"}] + ) + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": "12345", "rename": "Milk"}, + target={"entity_id": "todo.test_list"}, + blocking=True, + ) + assert ourgroceries.change_item_on_list + args = ourgroceries.change_item_on_list.call_args + assert args.args == ("test_list", "12345", "test_category", "Milk") + + +@pytest.mark.parametrize( + ("items"), + [ + [ + {"id": "12345", "name": "Soda"}, + {"id": "54321", "name": "Milk"}, + ] + ], +) +async def test_remove_todo_item( + hass: HomeAssistant, + setup_integration: None, + ourgroceries: AsyncMock, +) -> None: + """Test for removing an item.""" + + state = hass.states.get("todo.test_list") + assert state + assert state.state == "2" + + ourgroceries.remove_item_from_list = AsyncMock() + # Fake API response when state is refreshed after remove + ourgroceries.get_list_items.return_value = items_to_shopping_list([]) + + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": ["12345", "54321"]}, + target={"entity_id": "todo.test_list"}, + blocking=True, + ) + assert ourgroceries.remove_item_from_list.call_count == 2 + args = ourgroceries.remove_item_from_list.call_args_list + assert args[0].args == ("test_list", "12345") + assert args[1].args == ("test_list", "54321") + + await async_update_entity(hass, "todo.test_list") + state = hass.states.get("todo.test_list") + assert state + assert state.state == "0" + + +@pytest.mark.parametrize( + ("exception"), + [ + (ClientError), + (AsyncIOTimeoutError), + ], +) +async def test_coordinator_error( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_integration: None, + ourgroceries: AsyncMock, + exception: Exception, +) -> None: + """Test error on coordinator update.""" + state = hass.states.get("todo.test_list") + assert state.state == "0" + + ourgroceries.get_list_items.side_effect = exception + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("todo.test_list") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/overkiz/__init__.py b/tests/components/overkiz/__init__.py index d827bcb8334..407527b619e 100644 --- a/tests/components/overkiz/__init__.py +++ b/tests/components/overkiz/__init__.py @@ -1 +1,15 @@ """Tests for the overkiz component.""" +import humps +from pyoverkiz.models import Setup + +from tests.common import load_json_object_fixture + + +def load_setup_fixture( + fixture: str = "overkiz/setup_tahoma_switch.json", +) -> Setup: + """Return setup from fixture.""" + setup_json = load_json_object_fixture(fixture) + setup = Setup(**humps.decamelize(setup_json)) + + return setup diff --git a/tests/components/overkiz/conftest.py b/tests/components/overkiz/conftest.py index 6e00b6f5fe2..da6d3a60839 100644 --- a/tests/components/overkiz/conftest.py +++ b/tests/components/overkiz/conftest.py @@ -1,14 +1,60 @@ """Configuration for overkiz tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest +from homeassistant.components.overkiz.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.overkiz import load_setup_fixture +from tests.components.overkiz.test_config_flow import ( + TEST_EMAIL, + TEST_GATEWAY_ID, + TEST_PASSWORD, + TEST_SERVER, +) + +MOCK_SETUP_RESPONSE = Mock(devices=[], gateways=[]) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Somfy TaHoma Switch", + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER}, + ) + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: - """Override async_setup_entry.""" + """Mock setting up a config entry.""" with patch( "homeassistant.components.overkiz.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> MockConfigEntry: + """Set up the Overkiz integration for testing.""" + mock_config_entry.add_to_hass(hass) + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + login=AsyncMock(return_value=True), + get_setup=AsyncMock(return_value=load_setup_fixture()), + get_scenarios=AsyncMock(return_value=[]), + fetch_events=AsyncMock(return_value=[]), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/overkiz/fixtures/setup_tahoma_switch.json b/tests/components/overkiz/fixtures/setup_tahoma_switch.json new file mode 100644 index 00000000000..6b5d8beb7f9 --- /dev/null +++ b/tests/components/overkiz/fixtures/setup_tahoma_switch.json @@ -0,0 +1,891 @@ +{ + "creationTime": 1665238624000, + "lastUpdateTime": 1665238624000, + "id": "SETUP-****-****-6867", + "location": { + "creationTime": 1665238624000, + "lastUpdateTime": 1667054735000, + "city": "** **", + "country": "**", + "postalCode": "** **", + "addressLine1": "** **", + "addressLine2": "*", + "timezone": "Europe/Amsterdam", + "longitude": "**", + "latitude": "**", + "twilightMode": 2, + "twilightAngle": "SOLAR", + "twilightCity": "amsterdam", + "summerSolsticeDuskMinutes": 1290, + "winterSolsticeDuskMinutes": 990, + "twilightOffsetEnabled": false, + "dawnOffset": 0, + "duskOffset": 0, + "countryCode": "NL" + }, + "gateways": [ + { + "gatewayId": "****-****-6867", + "type": 98, + "subType": 1, + "placeOID": "41d63e43-bfa8-4e24-8c16-4faae0448cab", + "autoUpdateEnabled": true, + "alive": true, + "timeReliable": true, + "connectivity": { + "status": "OK", + "protocolVersion": "2023.4.4" + }, + "upToDate": true, + "updateStatus": "UP_TO_DATE", + "syncInProgress": false, + "mode": "ACTIVE", + "functions": "INTERNET_AUTHORIZATION,SCENARIO_DOWNLOAD,SCENARIO_AUTO_LAUNCHING,SCENARIO_TELECO_LAUNCHING,INTERNET_UPLOAD,INTERNET_UPDATE,TRIGGERS_SENSORS" + } + ], + "devices": [ + { + "creationTime": 1665238630000, + "lastUpdateTime": 1665238630000, + "label": "** *(**)*", + "deviceURL": "homekit://****-****-6867/stack", + "shortcut": false, + "controllableName": "homekit:StackComponent", + "definition": { + "commands": [ + { + "commandName": "deleteControllers", + "nparams": 0 + } + ], + "states": [], + "dataProperties": [], + "widgetName": "HomekitStack", + "uiProfiles": ["Specific"], + "uiClass": "ProtocolGateway", + "qualifiedName": "homekit:StackComponent", + "type": "PROTOCOL_GATEWAY" + }, + "attributes": [ + { + "name": "homekit:SetupPayload", + "type": 3, + "value": "**:*/*/**" + }, + { + "name": "homekit:SetupCode", + "type": 3, + "value": "**" + } + ], + "available": true, + "enabled": true, + "placeOID": "41d63e43-bfa8-4e24-8c16-4faae0448cab", + "type": 5, + "widget": "HomekitStack", + "oid": "ab964849-56ca-4e9c-a58c-33ce5e341b68", + "uiClass": "ProtocolGateway" + }, + { + "creationTime": 1665238630000, + "lastUpdateTime": 1665238630000, + "label": "**", + "deviceURL": "internal://****-****-6867/pod/0", + "shortcut": false, + "controllableName": "internal:PodV3Component", + "definition": { + "commands": [ + { + "commandName": "getName", + "nparams": 0 + }, + { + "commandName": "update", + "nparams": 0 + }, + { + "commandName": "setCountryCode", + "nparams": 1 + }, + { + "commandName": "activateCalendar", + "nparams": 0 + }, + { + "commandName": "deactivateCalendar", + "nparams": 0 + }, + { + "commandName": "refreshPodMode", + "nparams": 0 + }, + { + "commandName": "refreshUpdateStatus", + "nparams": 0 + }, + { + "commandName": "setCalendar", + "nparams": 1 + }, + { + "commandName": "setLightingLedPodMode", + "nparams": 1 + }, + { + "commandName": "setPodLedOff", + "nparams": 0 + }, + { + "commandName": "setPodLedOn", + "nparams": 0 + } + ], + "states": [ + { + "type": "DiscreteState", + "values": ["offline", "online"], + "qualifiedName": "core:ConnectivityState" + }, + { + "type": "DataState", + "qualifiedName": "core:CountryCodeState" + }, + { + "eventBased": true, + "type": "DataState", + "qualifiedName": "core:LocalAccessProofState" + }, + { + "type": "DataState", + "qualifiedName": "core:LocalIPv4AddressState" + }, + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "eventBased": true, + "type": "DiscreteState", + "values": ["pressed", "stop"], + "qualifiedName": "internal:Button1State" + }, + { + "eventBased": true, + "type": "DiscreteState", + "values": ["pressed", "stop"], + "qualifiedName": "internal:Button2State" + }, + { + "eventBased": true, + "type": "DiscreteState", + "values": ["pressed", "stop"], + "qualifiedName": "internal:Button3State" + }, + { + "type": "ContinuousState", + "qualifiedName": "internal:LightingLedPodModeState" + } + ], + "dataProperties": [], + "widgetName": "Pod", + "uiProfiles": ["UpdatableComponent"], + "uiClass": "Pod", + "qualifiedName": "internal:PodV3Component", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "internal:LightingLedPodModeState", + "type": 2, + "value": 1 + }, + { + "name": "core:CountryCodeState", + "type": 3, + "value": "NL" + }, + { + "name": "internal:Button1State", + "type": 3, + "value": "pressed" + }, + { + "name": "internal:Button3State", + "type": 3, + "value": "stop" + }, + { + "name": "core:LocalAccessProofState", + "type": 3, + "value": "localAccessProof" + }, + { + "name": "core:LocalIPv4AddressState", + "type": 3, + "value": "192.168.1.42" + }, + { + "name": "core:NameState", + "type": 3, + "value": "**" + } + ], + "available": true, + "enabled": true, + "placeOID": "41d63e43-bfa8-4e24-8c16-4faae0448cab", + "type": 1, + "widget": "Pod", + "oid": "c79a8bf6-59d6-434e-8cfd-97193541fa17", + "uiClass": "Pod" + }, + { + "creationTime": 1665238630000, + "lastUpdateTime": 1665238630000, + "label": "** *(**/**)*", + "deviceURL": "internal://****-****-6867/wifi/0", + "shortcut": false, + "controllableName": "internal:WifiComponent", + "definition": { + "commands": [ + { + "commandName": "clearCredentials", + "nparams": 0 + }, + { + "commandName": "setTargetInfraConfig", + "nparams": 2 + }, + { + "commandName": "setWifiMode", + "nparams": 1 + } + ], + "states": [ + { + "type": "DataState", + "qualifiedName": "internal:CurrentInfraConfigState" + }, + { + "type": "ContinuousState", + "qualifiedName": "internal:SignalStrengthState" + }, + { + "type": "DataState", + "qualifiedName": "internal:WifiModeState" + } + ], + "dataProperties": [], + "widgetName": "Wifi", + "uiProfiles": ["Specific"], + "uiClass": "Wifi", + "qualifiedName": "internal:WifiComponent", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "internal:WifiModeState", + "type": 3, + "value": "infrastructure" + }, + { + "name": "internal:CurrentInfraConfigState", + "type": 3, + "value": "AM" + }, + { + "name": "internal:SignalStrengthState", + "type": 1, + "value": 69 + } + ], + "available": true, + "enabled": true, + "placeOID": "41d63e43-bfa8-4e24-8c16-4faae0448cab", + "type": 1, + "widget": "Wifi", + "oid": "4272c61b-5493-453c-8d87-a58e45ef60f8", + "uiClass": "Wifi" + }, + { + "creationTime": 1665238924000, + "lastUpdateTime": 1665238924000, + "label": "** *(**)*", + "deviceURL": "io://****-****-6867/4167385", + "shortcut": false, + "controllableName": "io:StackComponent", + "definition": { + "commands": [ + { + "commandName": "advancedSomfyDiscover", + "nparams": 1 + }, + { + "commandName": "discover1WayController", + "nparams": 2 + }, + { + "commandName": "discoverActuators", + "nparams": 1 + }, + { + "commandName": "discoverSensors", + "nparams": 1 + }, + { + "commandName": "discoverSomfyUnsetActuators", + "nparams": 0 + }, + { + "commandName": "joinNetwork", + "nparams": 0 + }, + { + "commandName": "resetNetworkSecurity", + "nparams": 0 + }, + { + "commandName": "shareNetwork", + "nparams": 0 + } + ], + "states": [], + "dataProperties": [], + "widgetName": "IOStack", + "uiProfiles": ["Specific"], + "uiClass": "ProtocolGateway", + "qualifiedName": "io:StackComponent", + "type": "PROTOCOL_GATEWAY" + }, + "available": true, + "enabled": true, + "placeOID": "41d63e43-bfa8-4e24-8c16-4faae0448cab", + "type": 5, + "widget": "IOStack", + "oid": "bb301e56-0957-417f-ba37-26efc11659aa", + "uiClass": "ProtocolGateway" + }, + { + "creationTime": 1665238637000, + "lastUpdateTime": 1665238637000, + "label": "** ** **", + "deviceURL": "ogp://****-****-6867/00000BE8", + "shortcut": false, + "controllableName": "ogp:Bridge", + "definition": { + "commands": [ + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "sendPrivate", + "nparams": 1 + }, + { + "commandName": "setName", + "nparams": 1 + } + ], + "states": [ + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:AvailabilityState" + }, + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "type": "DataState", + "qualifiedName": "core:Private10State" + }, + { + "type": "DataState", + "qualifiedName": "core:Private1State" + }, + { + "type": "DataState", + "qualifiedName": "core:Private2State" + }, + { + "type": "DataState", + "qualifiedName": "core:Private3State" + }, + { + "type": "DataState", + "qualifiedName": "core:Private4State" + }, + { + "type": "DataState", + "qualifiedName": "core:Private5State" + }, + { + "type": "DataState", + "qualifiedName": "core:Private6State" + }, + { + "type": "DataState", + "qualifiedName": "core:Private7State" + }, + { + "type": "DataState", + "qualifiedName": "core:Private8State" + }, + { + "type": "DataState", + "qualifiedName": "core:Private9State" + }, + { + "type": "DataState", + "qualifiedName": "core:RemovableState" + } + ], + "dataProperties": [], + "widgetName": "DynamicBridge", + "uiProfiles": ["Specific"], + "uiClass": "ProtocolGateway", + "qualifiedName": "ogp:Bridge", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "core:NameState", + "type": 3, + "value": "** ** **" + } + ], + "attributes": [ + { + "name": "core:Technology", + "type": 3, + "value": "KNX" + }, + { + "name": "core:ManufacturerReference", + "type": 3, + "value": "OGP KNX Bridge" + }, + { + "name": "ogp:Features", + "type": 10, + "value": [ + { + "name": "private" + }, + { + "name": "identification" + } + ] + }, + { + "name": "core:Manufacturer", + "type": 3, + "value": "Overkiz" + } + ], + "available": true, + "enabled": true, + "placeOID": "41d63e43-bfa8-4e24-8c16-4faae0448cab", + "type": 1, + "widget": "DynamicBridge", + "oid": "e88717c3-02a9-49b6-a5a5-5adca558afe9", + "uiClass": "ProtocolGateway" + }, + { + "creationTime": 1665238799000, + "lastUpdateTime": 1665238799000, + "label": "** ** **", + "deviceURL": "ogp://****-****-6867/0003FEF3", + "shortcut": false, + "controllableName": "ogp:Bridge", + "definition": { + "commands": [ + { + "commandName": "discover", + "nparams": 0 + }, + { + "commandName": "reset", + "nparams": 0 + } + ], + "states": [ + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:AvailabilityState" + }, + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "type": "DataState", + "qualifiedName": "core:RemovableState" + } + ], + "dataProperties": [], + "widgetName": "DynamicBridge", + "uiProfiles": ["Specific"], + "uiClass": "ProtocolGateway", + "qualifiedName": "ogp:Bridge", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "core:NameState", + "type": 3, + "value": "** ** **" + } + ], + "attributes": [ + { + "name": "core:ManufacturerReference", + "type": 3, + "value": "OGP Sonos Bridge" + }, + { + "name": "core:Manufacturer", + "type": 3, + "value": "Overkiz" + }, + { + "name": "ogp:Features", + "type": 10, + "value": [ + { + "name": "identification", + "commandLess": true + }, + { + "name": "discovery" + }, + { + "name": "reset" + } + ] + }, + { + "name": "core:Technology", + "type": 3, + "value": "Sonos" + } + ], + "available": true, + "enabled": true, + "placeOID": "41d63e43-bfa8-4e24-8c16-4faae0448cab", + "type": 1, + "widget": "DynamicBridge", + "oid": "4031915f-df40-4a70-a97f-64031182a507", + "uiClass": "ProtocolGateway" + }, + { + "creationTime": 1665238637000, + "lastUpdateTime": 1665238637000, + "label": "** ** **", + "deviceURL": "ogp://****-****-6867/039575E9", + "shortcut": false, + "controllableName": "ogp:Bridge", + "definition": { + "commands": [ + { + "commandName": "discover", + "nparams": 0 + }, + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "setName", + "nparams": 1 + } + ], + "states": [ + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:AvailabilityState" + }, + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "type": "DataState", + "qualifiedName": "core:RemovableState" + } + ], + "dataProperties": [], + "widgetName": "DynamicBridge", + "uiProfiles": ["Specific"], + "uiClass": "ProtocolGateway", + "qualifiedName": "ogp:Bridge", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "core:NameState", + "type": 3, + "value": "** ** **" + } + ], + "attributes": [ + { + "name": "core:Manufacturer", + "type": 3, + "value": "Overkiz" + }, + { + "name": "ogp:Features", + "type": 10, + "value": [ + { + "name": "discovery" + }, + { + "name": "identification" + } + ] + }, + { + "name": "core:ManufacturerReference", + "type": 3, + "value": "OGP Siegenia Bridge" + }, + { + "name": "core:Technology", + "type": 3, + "value": "Siegenia" + } + ], + "available": true, + "enabled": true, + "placeOID": "41d63e43-bfa8-4e24-8c16-4faae0448cab", + "type": 1, + "widget": "DynamicBridge", + "oid": "5cdf0023-2d7e-4e8e-bfb0-74ebb6ebe0eb", + "uiClass": "ProtocolGateway" + }, + { + "creationTime": 1665238637000, + "lastUpdateTime": 1665238637000, + "label": "** ** **", + "deviceURL": "ogp://****-****-6867/09E45393", + "shortcut": false, + "controllableName": "ogp:Bridge", + "definition": { + "commands": [ + { + "commandName": "discover", + "nparams": 0 + }, + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "setName", + "nparams": 1 + } + ], + "states": [ + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:AvailabilityState" + }, + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "type": "DataState", + "qualifiedName": "core:RemovableState" + } + ], + "dataProperties": [], + "widgetName": "DynamicBridge", + "uiProfiles": ["Specific"], + "uiClass": "ProtocolGateway", + "qualifiedName": "ogp:Bridge", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "core:NameState", + "type": 3, + "value": "** ** **" + } + ], + "attributes": [ + { + "name": "core:ManufacturerReference", + "type": 3, + "value": "OGP Intesis Bridge" + }, + { + "name": "ogp:Features", + "type": 10, + "value": [ + { + "name": "discovery" + }, + { + "name": "identification" + } + ] + }, + { + "name": "core:Manufacturer", + "type": 3, + "value": "Overkiz" + }, + { + "name": "core:Technology", + "type": 3, + "value": "Intesis" + } + ], + "available": true, + "enabled": true, + "placeOID": "41d63e43-bfa8-4e24-8c16-4faae0448cab", + "type": 1, + "widget": "DynamicBridge", + "oid": "b1f5dc24-058e-4cb2-a052-7b2d5a7de7a5", + "uiClass": "ProtocolGateway" + }, + { + "creationTime": 1667840384000, + "lastUpdateTime": 1667840384000, + "label": "** ** **", + "deviceURL": "rts://****-****-6867/16756006", + "shortcut": false, + "controllableName": "rts:RollerShutterRTSComponent", + "definition": { + "commands": [ + { + "commandName": "close", + "nparams": 1 + }, + { + "commandName": "down", + "nparams": 1 + }, + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "my", + "nparams": 1 + }, + { + "commandName": "open", + "nparams": 1 + }, + { + "commandName": "rest", + "nparams": 1 + }, + { + "commandName": "stop", + "nparams": 1 + }, + { + "commandName": "test", + "nparams": 0 + }, + { + "commandName": "up", + "nparams": 1 + }, + { + "commandName": "openConfiguration", + "nparams": 1 + } + ], + "states": [], + "dataProperties": [ + { + "value": "0", + "qualifiedName": "core:identifyInterval" + } + ], + "widgetName": "UpDownRollerShutter", + "uiProfiles": ["OpenCloseShutter", "OpenClose"], + "uiClass": "RollerShutter", + "qualifiedName": "rts:RollerShutterRTSComponent", + "type": "ACTUATOR" + }, + "attributes": [ + { + "name": "rts:diy", + "type": 6, + "value": true + } + ], + "available": true, + "enabled": true, + "placeOID": "9e3d6899-50bb-4869-9c5e-46c2b57f7c9e", + "type": 1, + "widget": "UpDownRollerShutter", + "oid": "1a10d6f6-9bc3-40f3-a33c-e383fd41d3e8", + "uiClass": "RollerShutter" + }, + { + "creationTime": 1665238630000, + "lastUpdateTime": 1665238630000, + "label": "** *(**)*", + "deviceURL": "zigbee://****-****-6867/65535", + "shortcut": false, + "controllableName": "zigbee:TransceiverV3_0Component", + "definition": { + "commands": [], + "states": [], + "dataProperties": [], + "widgetName": "ZigbeeStack", + "uiProfiles": ["Specific"], + "uiClass": "ProtocolGateway", + "qualifiedName": "zigbee:TransceiverV3_0Component", + "type": "PROTOCOL_GATEWAY" + }, + "available": true, + "enabled": true, + "placeOID": "41d63e43-bfa8-4e24-8c16-4faae0448cab", + "type": 5, + "widget": "ZigbeeStack", + "oid": "1629c223-d115-4aad-808a-373f428d9c27", + "uiClass": "ProtocolGateway" + } + ], + "zones": [], + "resellerDelegationType": "NEVER", + "disconnectionConfiguration": { + "notificationTitle": "[User] : Your Somfy box is disconnected", + "notificationText": "Your Somfy box is disconnected", + "targetPushSubscriptions": ["8849df9a-b61a-498f-ab81-67a767adba31"], + "notificationType": "PUSH" + }, + "oid": "15eaf55a-8af9-483b-ae4a-ffd4254fd762", + "rootPlace": { + "creationTime": 1665238624000, + "lastUpdateTime": 1665238630000, + "label": "** **", + "type": 200, + "oid": "41d63e43-bfa8-4e24-8c16-4faae0448cab", + "subPlaces": [ + { + "creationTime": 1667840432000, + "lastUpdateTime": 1667840432000, + "label": "**", + "type": 108, + "metadata": "{\"color\":\"#08C27F\"}", + "oid": "9e3d6899-50bb-4869-9c5e-46c2b57f7c9e", + "subPlaces": [] + } + ] + }, + "features": [] +} diff --git a/tests/components/overkiz/snapshots/test_diagnostics.ambr b/tests/components/overkiz/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..a4ba28ec935 --- /dev/null +++ b/tests/components/overkiz/snapshots/test_diagnostics.ambr @@ -0,0 +1,1933 @@ +# serializer version: 1 +# name: test_device_diagnostics + dict({ + 'api_type': 'cloud', + 'device': dict({ + 'controllable_name': 'rts:RollerShutterRTSComponent', + 'device_url': 'rts://****-****-6867/16756006', + 'firmware': None, + 'model': 'UpDownRollerShutter', + }), + 'execution_history': list([ + ]), + 'server': 'somfy_europe', + 'setup': dict({ + 'creationTime': 1665238624000, + 'devices': list([ + dict({ + 'attributes': list([ + dict({ + 'name': 'homekit:SetupPayload', + 'type': 3, + 'value': '**:*/*/**', + }), + dict({ + 'name': 'homekit:SetupCode', + 'type': 3, + 'value': '**', + }), + ]), + 'available': True, + 'controllableName': 'homekit:StackComponent', + 'creationTime': 1665238630000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'deleteControllers', + 'nparams': 0, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'homekit:StackComponent', + 'states': list([ + ]), + 'type': 'PROTOCOL_GATEWAY', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'HomekitStack', + }), + 'deviceURL': 'homekit://****-****-6867/stack', + 'enabled': True, + 'label': '** *(**)*', + 'lastUpdateTime': 1665238630000, + 'oid': 'ab964849-56ca-4e9c-a58c-33ce5e341b68', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'type': 5, + 'uiClass': 'ProtocolGateway', + 'widget': 'HomekitStack', + }), + dict({ + 'available': True, + 'controllableName': 'internal:PodV3Component', + 'creationTime': 1665238630000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'getName', + 'nparams': 0, + }), + dict({ + 'commandName': 'update', + 'nparams': 0, + }), + dict({ + 'commandName': 'setCountryCode', + 'nparams': 1, + }), + dict({ + 'commandName': 'activateCalendar', + 'nparams': 0, + }), + dict({ + 'commandName': 'deactivateCalendar', + 'nparams': 0, + }), + dict({ + 'commandName': 'refreshPodMode', + 'nparams': 0, + }), + dict({ + 'commandName': 'refreshUpdateStatus', + 'nparams': 0, + }), + dict({ + 'commandName': 'setCalendar', + 'nparams': 1, + }), + dict({ + 'commandName': 'setLightingLedPodMode', + 'nparams': 1, + }), + dict({ + 'commandName': 'setPodLedOff', + 'nparams': 0, + }), + dict({ + 'commandName': 'setPodLedOn', + 'nparams': 0, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'internal:PodV3Component', + 'states': list([ + dict({ + 'qualifiedName': 'core:ConnectivityState', + 'type': 'DiscreteState', + 'values': list([ + 'offline', + 'online', + ]), + }), + dict({ + 'qualifiedName': 'core:CountryCodeState', + 'type': 'DataState', + }), + dict({ + 'eventBased': True, + 'qualifiedName': 'core:LocalAccessProofState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:LocalIPv4AddressState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:NameState', + 'type': 'DataState', + }), + dict({ + 'eventBased': True, + 'qualifiedName': 'internal:Button1State', + 'type': 'DiscreteState', + 'values': list([ + 'pressed', + 'stop', + ]), + }), + dict({ + 'eventBased': True, + 'qualifiedName': 'internal:Button2State', + 'type': 'DiscreteState', + 'values': list([ + 'pressed', + 'stop', + ]), + }), + dict({ + 'eventBased': True, + 'qualifiedName': 'internal:Button3State', + 'type': 'DiscreteState', + 'values': list([ + 'pressed', + 'stop', + ]), + }), + dict({ + 'qualifiedName': 'internal:LightingLedPodModeState', + 'type': 'ContinuousState', + }), + ]), + 'type': 'ACTUATOR', + 'uiClass': 'Pod', + 'uiProfiles': list([ + 'UpdatableComponent', + ]), + 'widgetName': 'Pod', + }), + 'deviceURL': 'internal://****-****-6867/pod/0', + 'enabled': True, + 'label': '**', + 'lastUpdateTime': 1665238630000, + 'oid': 'c79a8bf6-59d6-434e-8cfd-97193541fa17', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'states': list([ + dict({ + 'name': 'internal:LightingLedPodModeState', + 'type': 2, + 'value': 1, + }), + dict({ + 'name': 'core:CountryCodeState', + 'type': 3, + 'value': 'NL', + }), + dict({ + 'name': 'internal:Button1State', + 'type': 3, + 'value': 'pressed', + }), + dict({ + 'name': 'internal:Button3State', + 'type': 3, + 'value': 'stop', + }), + dict({ + 'name': 'core:LocalAccessProofState', + 'type': 3, + 'value': 'localAccessProof', + }), + dict({ + 'name': 'core:LocalIPv4AddressState', + 'type': 3, + 'value': '192.168.1.42', + }), + dict({ + 'name': 'core:NameState', + 'type': 3, + 'value': '**', + }), + ]), + 'type': 1, + 'uiClass': 'Pod', + 'widget': 'Pod', + }), + dict({ + 'available': True, + 'controllableName': 'internal:WifiComponent', + 'creationTime': 1665238630000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'clearCredentials', + 'nparams': 0, + }), + dict({ + 'commandName': 'setTargetInfraConfig', + 'nparams': 2, + }), + dict({ + 'commandName': 'setWifiMode', + 'nparams': 1, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'internal:WifiComponent', + 'states': list([ + dict({ + 'qualifiedName': 'internal:CurrentInfraConfigState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'internal:SignalStrengthState', + 'type': 'ContinuousState', + }), + dict({ + 'qualifiedName': 'internal:WifiModeState', + 'type': 'DataState', + }), + ]), + 'type': 'ACTUATOR', + 'uiClass': 'Wifi', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'Wifi', + }), + 'deviceURL': 'internal://****-****-6867/wifi/0', + 'enabled': True, + 'label': '** *(**/**)*', + 'lastUpdateTime': 1665238630000, + 'oid': '4272c61b-5493-453c-8d87-a58e45ef60f8', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'states': list([ + dict({ + 'name': 'internal:WifiModeState', + 'type': 3, + 'value': 'infrastructure', + }), + dict({ + 'name': 'internal:CurrentInfraConfigState', + 'type': 3, + 'value': 'AM', + }), + dict({ + 'name': 'internal:SignalStrengthState', + 'type': 1, + 'value': 69, + }), + ]), + 'type': 1, + 'uiClass': 'Wifi', + 'widget': 'Wifi', + }), + dict({ + 'available': True, + 'controllableName': 'io:StackComponent', + 'creationTime': 1665238924000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'advancedSomfyDiscover', + 'nparams': 1, + }), + dict({ + 'commandName': 'discover1WayController', + 'nparams': 2, + }), + dict({ + 'commandName': 'discoverActuators', + 'nparams': 1, + }), + dict({ + 'commandName': 'discoverSensors', + 'nparams': 1, + }), + dict({ + 'commandName': 'discoverSomfyUnsetActuators', + 'nparams': 0, + }), + dict({ + 'commandName': 'joinNetwork', + 'nparams': 0, + }), + dict({ + 'commandName': 'resetNetworkSecurity', + 'nparams': 0, + }), + dict({ + 'commandName': 'shareNetwork', + 'nparams': 0, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'io:StackComponent', + 'states': list([ + ]), + 'type': 'PROTOCOL_GATEWAY', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'IOStack', + }), + 'deviceURL': 'io://****-****-6867/4167385', + 'enabled': True, + 'label': '** *(**)*', + 'lastUpdateTime': 1665238924000, + 'oid': 'bb301e56-0957-417f-ba37-26efc11659aa', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'type': 5, + 'uiClass': 'ProtocolGateway', + 'widget': 'IOStack', + }), + dict({ + 'attributes': list([ + dict({ + 'name': 'core:Technology', + 'type': 3, + 'value': 'KNX', + }), + dict({ + 'name': 'core:ManufacturerReference', + 'type': 3, + 'value': 'OGP KNX Bridge', + }), + dict({ + 'name': 'ogp:Features', + 'type': 10, + 'value': list([ + dict({ + 'name': 'private', + }), + dict({ + 'name': 'identification', + }), + ]), + }), + dict({ + 'name': 'core:Manufacturer', + 'type': 3, + 'value': 'Overkiz', + }), + ]), + 'available': True, + 'controllableName': 'ogp:Bridge', + 'creationTime': 1665238637000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'identify', + 'nparams': 0, + }), + dict({ + 'commandName': 'sendPrivate', + 'nparams': 1, + }), + dict({ + 'commandName': 'setName', + 'nparams': 1, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'ogp:Bridge', + 'states': list([ + dict({ + 'qualifiedName': 'core:AvailabilityState', + 'type': 'DiscreteState', + 'values': list([ + 'available', + 'unavailable', + ]), + }), + dict({ + 'qualifiedName': 'core:NameState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private10State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private1State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private2State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private3State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private4State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private5State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private6State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private7State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private8State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private9State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:RemovableState', + 'type': 'DataState', + }), + ]), + 'type': 'ACTUATOR', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'DynamicBridge', + }), + 'deviceURL': 'ogp://****-****-6867/00000BE8', + 'enabled': True, + 'label': '** ** **', + 'lastUpdateTime': 1665238637000, + 'oid': 'e88717c3-02a9-49b6-a5a5-5adca558afe9', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'states': list([ + dict({ + 'name': 'core:NameState', + 'type': 3, + 'value': '** ** **', + }), + ]), + 'type': 1, + 'uiClass': 'ProtocolGateway', + 'widget': 'DynamicBridge', + }), + dict({ + 'attributes': list([ + dict({ + 'name': 'core:ManufacturerReference', + 'type': 3, + 'value': 'OGP Sonos Bridge', + }), + dict({ + 'name': 'core:Manufacturer', + 'type': 3, + 'value': 'Overkiz', + }), + dict({ + 'name': 'ogp:Features', + 'type': 10, + 'value': list([ + dict({ + 'commandLess': True, + 'name': 'identification', + }), + dict({ + 'name': 'discovery', + }), + dict({ + 'name': 'reset', + }), + ]), + }), + dict({ + 'name': 'core:Technology', + 'type': 3, + 'value': 'Sonos', + }), + ]), + 'available': True, + 'controllableName': 'ogp:Bridge', + 'creationTime': 1665238799000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'discover', + 'nparams': 0, + }), + dict({ + 'commandName': 'reset', + 'nparams': 0, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'ogp:Bridge', + 'states': list([ + dict({ + 'qualifiedName': 'core:AvailabilityState', + 'type': 'DiscreteState', + 'values': list([ + 'available', + 'unavailable', + ]), + }), + dict({ + 'qualifiedName': 'core:NameState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:RemovableState', + 'type': 'DataState', + }), + ]), + 'type': 'ACTUATOR', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'DynamicBridge', + }), + 'deviceURL': 'ogp://****-****-6867/0003FEF3', + 'enabled': True, + 'label': '** ** **', + 'lastUpdateTime': 1665238799000, + 'oid': '4031915f-df40-4a70-a97f-64031182a507', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'states': list([ + dict({ + 'name': 'core:NameState', + 'type': 3, + 'value': '** ** **', + }), + ]), + 'type': 1, + 'uiClass': 'ProtocolGateway', + 'widget': 'DynamicBridge', + }), + dict({ + 'attributes': list([ + dict({ + 'name': 'core:Manufacturer', + 'type': 3, + 'value': 'Overkiz', + }), + dict({ + 'name': 'ogp:Features', + 'type': 10, + 'value': list([ + dict({ + 'name': 'discovery', + }), + dict({ + 'name': 'identification', + }), + ]), + }), + dict({ + 'name': 'core:ManufacturerReference', + 'type': 3, + 'value': 'OGP Siegenia Bridge', + }), + dict({ + 'name': 'core:Technology', + 'type': 3, + 'value': 'Siegenia', + }), + ]), + 'available': True, + 'controllableName': 'ogp:Bridge', + 'creationTime': 1665238637000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'discover', + 'nparams': 0, + }), + dict({ + 'commandName': 'identify', + 'nparams': 0, + }), + dict({ + 'commandName': 'setName', + 'nparams': 1, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'ogp:Bridge', + 'states': list([ + dict({ + 'qualifiedName': 'core:AvailabilityState', + 'type': 'DiscreteState', + 'values': list([ + 'available', + 'unavailable', + ]), + }), + dict({ + 'qualifiedName': 'core:NameState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:RemovableState', + 'type': 'DataState', + }), + ]), + 'type': 'ACTUATOR', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'DynamicBridge', + }), + 'deviceURL': 'ogp://****-****-6867/039575E9', + 'enabled': True, + 'label': '** ** **', + 'lastUpdateTime': 1665238637000, + 'oid': '5cdf0023-2d7e-4e8e-bfb0-74ebb6ebe0eb', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'states': list([ + dict({ + 'name': 'core:NameState', + 'type': 3, + 'value': '** ** **', + }), + ]), + 'type': 1, + 'uiClass': 'ProtocolGateway', + 'widget': 'DynamicBridge', + }), + dict({ + 'attributes': list([ + dict({ + 'name': 'core:ManufacturerReference', + 'type': 3, + 'value': 'OGP Intesis Bridge', + }), + dict({ + 'name': 'ogp:Features', + 'type': 10, + 'value': list([ + dict({ + 'name': 'discovery', + }), + dict({ + 'name': 'identification', + }), + ]), + }), + dict({ + 'name': 'core:Manufacturer', + 'type': 3, + 'value': 'Overkiz', + }), + dict({ + 'name': 'core:Technology', + 'type': 3, + 'value': 'Intesis', + }), + ]), + 'available': True, + 'controllableName': 'ogp:Bridge', + 'creationTime': 1665238637000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'discover', + 'nparams': 0, + }), + dict({ + 'commandName': 'identify', + 'nparams': 0, + }), + dict({ + 'commandName': 'setName', + 'nparams': 1, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'ogp:Bridge', + 'states': list([ + dict({ + 'qualifiedName': 'core:AvailabilityState', + 'type': 'DiscreteState', + 'values': list([ + 'available', + 'unavailable', + ]), + }), + dict({ + 'qualifiedName': 'core:NameState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:RemovableState', + 'type': 'DataState', + }), + ]), + 'type': 'ACTUATOR', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'DynamicBridge', + }), + 'deviceURL': 'ogp://****-****-6867/09E45393', + 'enabled': True, + 'label': '** ** **', + 'lastUpdateTime': 1665238637000, + 'oid': 'b1f5dc24-058e-4cb2-a052-7b2d5a7de7a5', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'states': list([ + dict({ + 'name': 'core:NameState', + 'type': 3, + 'value': '** ** **', + }), + ]), + 'type': 1, + 'uiClass': 'ProtocolGateway', + 'widget': 'DynamicBridge', + }), + dict({ + 'attributes': list([ + dict({ + 'name': 'rts:diy', + 'type': 6, + 'value': True, + }), + ]), + 'available': True, + 'controllableName': 'rts:RollerShutterRTSComponent', + 'creationTime': 1667840384000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'close', + 'nparams': 1, + }), + dict({ + 'commandName': 'down', + 'nparams': 1, + }), + dict({ + 'commandName': 'identify', + 'nparams': 0, + }), + dict({ + 'commandName': 'my', + 'nparams': 1, + }), + dict({ + 'commandName': 'open', + 'nparams': 1, + }), + dict({ + 'commandName': 'rest', + 'nparams': 1, + }), + dict({ + 'commandName': 'stop', + 'nparams': 1, + }), + dict({ + 'commandName': 'test', + 'nparams': 0, + }), + dict({ + 'commandName': 'up', + 'nparams': 1, + }), + dict({ + 'commandName': 'openConfiguration', + 'nparams': 1, + }), + ]), + 'dataProperties': list([ + dict({ + 'qualifiedName': 'core:identifyInterval', + 'value': '0', + }), + ]), + 'qualifiedName': 'rts:RollerShutterRTSComponent', + 'states': list([ + ]), + 'type': 'ACTUATOR', + 'uiClass': 'RollerShutter', + 'uiProfiles': list([ + 'OpenCloseShutter', + 'OpenClose', + ]), + 'widgetName': 'UpDownRollerShutter', + }), + 'deviceURL': 'rts://****-****-6867/16756006', + 'enabled': True, + 'label': '** ** **', + 'lastUpdateTime': 1667840384000, + 'oid': '1a10d6f6-9bc3-40f3-a33c-e383fd41d3e8', + 'placeOID': '9e3d6899-50bb-4869-9c5e-46c2b57f7c9e', + 'shortcut': False, + 'type': 1, + 'uiClass': 'RollerShutter', + 'widget': 'UpDownRollerShutter', + }), + dict({ + 'available': True, + 'controllableName': 'zigbee:TransceiverV3_0Component', + 'creationTime': 1665238630000, + 'definition': dict({ + 'commands': list([ + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'zigbee:TransceiverV3_0Component', + 'states': list([ + ]), + 'type': 'PROTOCOL_GATEWAY', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'ZigbeeStack', + }), + 'deviceURL': 'zigbee://****-****-6867/65535', + 'enabled': True, + 'label': '** *(**)*', + 'lastUpdateTime': 1665238630000, + 'oid': '1629c223-d115-4aad-808a-373f428d9c27', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'type': 5, + 'uiClass': 'ProtocolGateway', + 'widget': 'ZigbeeStack', + }), + ]), + 'disconnectionConfiguration': dict({ + 'notificationText': 'Your Somfy box is disconnected', + 'notificationTitle': '[User] : Your Somfy box is disconnected', + 'notificationType': 'PUSH', + 'targetPushSubscriptions': list([ + '8849df9a-b61a-498f-ab81-67a767adba31', + ]), + }), + 'features': list([ + ]), + 'gateways': list([ + dict({ + 'alive': True, + 'autoUpdateEnabled': True, + 'connectivity': dict({ + 'protocolVersion': '2023.4.4', + 'status': 'OK', + }), + 'functions': 'INTERNET_AUTHORIZATION,SCENARIO_DOWNLOAD,SCENARIO_AUTO_LAUNCHING,SCENARIO_TELECO_LAUNCHING,INTERNET_UPLOAD,INTERNET_UPDATE,TRIGGERS_SENSORS', + 'gatewayId': '****-****-6867', + 'mode': 'ACTIVE', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'subType': 1, + 'syncInProgress': False, + 'timeReliable': True, + 'type': 98, + 'upToDate': True, + 'updateStatus': 'UP_TO_DATE', + }), + ]), + 'id': 'SETUP-****-****-6867', + 'lastUpdateTime': 1665238624000, + 'location': dict({ + 'addressLine1': '** **', + 'addressLine2': '*', + 'city': '** **', + 'country': '**', + 'countryCode': 'NL', + 'creationTime': 1665238624000, + 'dawnOffset': 0, + 'duskOffset': 0, + 'lastUpdateTime': 1667054735000, + 'latitude': '**', + 'longitude': '**', + 'postalCode': '** **', + 'summerSolsticeDuskMinutes': 1290, + 'timezone': 'Europe/Amsterdam', + 'twilightAngle': 'SOLAR', + 'twilightCity': 'amsterdam', + 'twilightMode': 2, + 'twilightOffsetEnabled': False, + 'winterSolsticeDuskMinutes': 990, + }), + 'oid': '15eaf55a-8af9-483b-ae4a-ffd4254fd762', + 'resellerDelegationType': 'NEVER', + 'rootPlace': dict({ + 'creationTime': 1665238624000, + 'label': '** **', + 'lastUpdateTime': 1665238630000, + 'oid': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'subPlaces': list([ + dict({ + 'creationTime': 1667840432000, + 'label': '**', + 'lastUpdateTime': 1667840432000, + 'metadata': '{"color":"#08C27F"}', + 'oid': '9e3d6899-50bb-4869-9c5e-46c2b57f7c9e', + 'subPlaces': list([ + ]), + 'type': 108, + }), + ]), + 'type': 200, + }), + 'zones': list([ + ]), + }), + }) +# --- +# name: test_diagnostics + dict({ + 'api_type': 'cloud', + 'execution_history': list([ + ]), + 'server': 'somfy_europe', + 'setup': dict({ + 'creationTime': 1665238624000, + 'devices': list([ + dict({ + 'attributes': list([ + dict({ + 'name': 'homekit:SetupPayload', + 'type': 3, + 'value': '**:*/*/**', + }), + dict({ + 'name': 'homekit:SetupCode', + 'type': 3, + 'value': '**', + }), + ]), + 'available': True, + 'controllableName': 'homekit:StackComponent', + 'creationTime': 1665238630000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'deleteControllers', + 'nparams': 0, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'homekit:StackComponent', + 'states': list([ + ]), + 'type': 'PROTOCOL_GATEWAY', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'HomekitStack', + }), + 'deviceURL': 'homekit://****-****-6867/stack', + 'enabled': True, + 'label': '** *(**)*', + 'lastUpdateTime': 1665238630000, + 'oid': 'ab964849-56ca-4e9c-a58c-33ce5e341b68', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'type': 5, + 'uiClass': 'ProtocolGateway', + 'widget': 'HomekitStack', + }), + dict({ + 'available': True, + 'controllableName': 'internal:PodV3Component', + 'creationTime': 1665238630000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'getName', + 'nparams': 0, + }), + dict({ + 'commandName': 'update', + 'nparams': 0, + }), + dict({ + 'commandName': 'setCountryCode', + 'nparams': 1, + }), + dict({ + 'commandName': 'activateCalendar', + 'nparams': 0, + }), + dict({ + 'commandName': 'deactivateCalendar', + 'nparams': 0, + }), + dict({ + 'commandName': 'refreshPodMode', + 'nparams': 0, + }), + dict({ + 'commandName': 'refreshUpdateStatus', + 'nparams': 0, + }), + dict({ + 'commandName': 'setCalendar', + 'nparams': 1, + }), + dict({ + 'commandName': 'setLightingLedPodMode', + 'nparams': 1, + }), + dict({ + 'commandName': 'setPodLedOff', + 'nparams': 0, + }), + dict({ + 'commandName': 'setPodLedOn', + 'nparams': 0, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'internal:PodV3Component', + 'states': list([ + dict({ + 'qualifiedName': 'core:ConnectivityState', + 'type': 'DiscreteState', + 'values': list([ + 'offline', + 'online', + ]), + }), + dict({ + 'qualifiedName': 'core:CountryCodeState', + 'type': 'DataState', + }), + dict({ + 'eventBased': True, + 'qualifiedName': 'core:LocalAccessProofState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:LocalIPv4AddressState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:NameState', + 'type': 'DataState', + }), + dict({ + 'eventBased': True, + 'qualifiedName': 'internal:Button1State', + 'type': 'DiscreteState', + 'values': list([ + 'pressed', + 'stop', + ]), + }), + dict({ + 'eventBased': True, + 'qualifiedName': 'internal:Button2State', + 'type': 'DiscreteState', + 'values': list([ + 'pressed', + 'stop', + ]), + }), + dict({ + 'eventBased': True, + 'qualifiedName': 'internal:Button3State', + 'type': 'DiscreteState', + 'values': list([ + 'pressed', + 'stop', + ]), + }), + dict({ + 'qualifiedName': 'internal:LightingLedPodModeState', + 'type': 'ContinuousState', + }), + ]), + 'type': 'ACTUATOR', + 'uiClass': 'Pod', + 'uiProfiles': list([ + 'UpdatableComponent', + ]), + 'widgetName': 'Pod', + }), + 'deviceURL': 'internal://****-****-6867/pod/0', + 'enabled': True, + 'label': '**', + 'lastUpdateTime': 1665238630000, + 'oid': 'c79a8bf6-59d6-434e-8cfd-97193541fa17', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'states': list([ + dict({ + 'name': 'internal:LightingLedPodModeState', + 'type': 2, + 'value': 1, + }), + dict({ + 'name': 'core:CountryCodeState', + 'type': 3, + 'value': 'NL', + }), + dict({ + 'name': 'internal:Button1State', + 'type': 3, + 'value': 'pressed', + }), + dict({ + 'name': 'internal:Button3State', + 'type': 3, + 'value': 'stop', + }), + dict({ + 'name': 'core:LocalAccessProofState', + 'type': 3, + 'value': 'localAccessProof', + }), + dict({ + 'name': 'core:LocalIPv4AddressState', + 'type': 3, + 'value': '192.168.1.42', + }), + dict({ + 'name': 'core:NameState', + 'type': 3, + 'value': '**', + }), + ]), + 'type': 1, + 'uiClass': 'Pod', + 'widget': 'Pod', + }), + dict({ + 'available': True, + 'controllableName': 'internal:WifiComponent', + 'creationTime': 1665238630000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'clearCredentials', + 'nparams': 0, + }), + dict({ + 'commandName': 'setTargetInfraConfig', + 'nparams': 2, + }), + dict({ + 'commandName': 'setWifiMode', + 'nparams': 1, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'internal:WifiComponent', + 'states': list([ + dict({ + 'qualifiedName': 'internal:CurrentInfraConfigState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'internal:SignalStrengthState', + 'type': 'ContinuousState', + }), + dict({ + 'qualifiedName': 'internal:WifiModeState', + 'type': 'DataState', + }), + ]), + 'type': 'ACTUATOR', + 'uiClass': 'Wifi', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'Wifi', + }), + 'deviceURL': 'internal://****-****-6867/wifi/0', + 'enabled': True, + 'label': '** *(**/**)*', + 'lastUpdateTime': 1665238630000, + 'oid': '4272c61b-5493-453c-8d87-a58e45ef60f8', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'states': list([ + dict({ + 'name': 'internal:WifiModeState', + 'type': 3, + 'value': 'infrastructure', + }), + dict({ + 'name': 'internal:CurrentInfraConfigState', + 'type': 3, + 'value': 'AM', + }), + dict({ + 'name': 'internal:SignalStrengthState', + 'type': 1, + 'value': 69, + }), + ]), + 'type': 1, + 'uiClass': 'Wifi', + 'widget': 'Wifi', + }), + dict({ + 'available': True, + 'controllableName': 'io:StackComponent', + 'creationTime': 1665238924000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'advancedSomfyDiscover', + 'nparams': 1, + }), + dict({ + 'commandName': 'discover1WayController', + 'nparams': 2, + }), + dict({ + 'commandName': 'discoverActuators', + 'nparams': 1, + }), + dict({ + 'commandName': 'discoverSensors', + 'nparams': 1, + }), + dict({ + 'commandName': 'discoverSomfyUnsetActuators', + 'nparams': 0, + }), + dict({ + 'commandName': 'joinNetwork', + 'nparams': 0, + }), + dict({ + 'commandName': 'resetNetworkSecurity', + 'nparams': 0, + }), + dict({ + 'commandName': 'shareNetwork', + 'nparams': 0, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'io:StackComponent', + 'states': list([ + ]), + 'type': 'PROTOCOL_GATEWAY', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'IOStack', + }), + 'deviceURL': 'io://****-****-6867/4167385', + 'enabled': True, + 'label': '** *(**)*', + 'lastUpdateTime': 1665238924000, + 'oid': 'bb301e56-0957-417f-ba37-26efc11659aa', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'type': 5, + 'uiClass': 'ProtocolGateway', + 'widget': 'IOStack', + }), + dict({ + 'attributes': list([ + dict({ + 'name': 'core:Technology', + 'type': 3, + 'value': 'KNX', + }), + dict({ + 'name': 'core:ManufacturerReference', + 'type': 3, + 'value': 'OGP KNX Bridge', + }), + dict({ + 'name': 'ogp:Features', + 'type': 10, + 'value': list([ + dict({ + 'name': 'private', + }), + dict({ + 'name': 'identification', + }), + ]), + }), + dict({ + 'name': 'core:Manufacturer', + 'type': 3, + 'value': 'Overkiz', + }), + ]), + 'available': True, + 'controllableName': 'ogp:Bridge', + 'creationTime': 1665238637000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'identify', + 'nparams': 0, + }), + dict({ + 'commandName': 'sendPrivate', + 'nparams': 1, + }), + dict({ + 'commandName': 'setName', + 'nparams': 1, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'ogp:Bridge', + 'states': list([ + dict({ + 'qualifiedName': 'core:AvailabilityState', + 'type': 'DiscreteState', + 'values': list([ + 'available', + 'unavailable', + ]), + }), + dict({ + 'qualifiedName': 'core:NameState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private10State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private1State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private2State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private3State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private4State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private5State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private6State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private7State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private8State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:Private9State', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:RemovableState', + 'type': 'DataState', + }), + ]), + 'type': 'ACTUATOR', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'DynamicBridge', + }), + 'deviceURL': 'ogp://****-****-6867/00000BE8', + 'enabled': True, + 'label': '** ** **', + 'lastUpdateTime': 1665238637000, + 'oid': 'e88717c3-02a9-49b6-a5a5-5adca558afe9', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'states': list([ + dict({ + 'name': 'core:NameState', + 'type': 3, + 'value': '** ** **', + }), + ]), + 'type': 1, + 'uiClass': 'ProtocolGateway', + 'widget': 'DynamicBridge', + }), + dict({ + 'attributes': list([ + dict({ + 'name': 'core:ManufacturerReference', + 'type': 3, + 'value': 'OGP Sonos Bridge', + }), + dict({ + 'name': 'core:Manufacturer', + 'type': 3, + 'value': 'Overkiz', + }), + dict({ + 'name': 'ogp:Features', + 'type': 10, + 'value': list([ + dict({ + 'commandLess': True, + 'name': 'identification', + }), + dict({ + 'name': 'discovery', + }), + dict({ + 'name': 'reset', + }), + ]), + }), + dict({ + 'name': 'core:Technology', + 'type': 3, + 'value': 'Sonos', + }), + ]), + 'available': True, + 'controllableName': 'ogp:Bridge', + 'creationTime': 1665238799000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'discover', + 'nparams': 0, + }), + dict({ + 'commandName': 'reset', + 'nparams': 0, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'ogp:Bridge', + 'states': list([ + dict({ + 'qualifiedName': 'core:AvailabilityState', + 'type': 'DiscreteState', + 'values': list([ + 'available', + 'unavailable', + ]), + }), + dict({ + 'qualifiedName': 'core:NameState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:RemovableState', + 'type': 'DataState', + }), + ]), + 'type': 'ACTUATOR', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'DynamicBridge', + }), + 'deviceURL': 'ogp://****-****-6867/0003FEF3', + 'enabled': True, + 'label': '** ** **', + 'lastUpdateTime': 1665238799000, + 'oid': '4031915f-df40-4a70-a97f-64031182a507', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'states': list([ + dict({ + 'name': 'core:NameState', + 'type': 3, + 'value': '** ** **', + }), + ]), + 'type': 1, + 'uiClass': 'ProtocolGateway', + 'widget': 'DynamicBridge', + }), + dict({ + 'attributes': list([ + dict({ + 'name': 'core:Manufacturer', + 'type': 3, + 'value': 'Overkiz', + }), + dict({ + 'name': 'ogp:Features', + 'type': 10, + 'value': list([ + dict({ + 'name': 'discovery', + }), + dict({ + 'name': 'identification', + }), + ]), + }), + dict({ + 'name': 'core:ManufacturerReference', + 'type': 3, + 'value': 'OGP Siegenia Bridge', + }), + dict({ + 'name': 'core:Technology', + 'type': 3, + 'value': 'Siegenia', + }), + ]), + 'available': True, + 'controllableName': 'ogp:Bridge', + 'creationTime': 1665238637000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'discover', + 'nparams': 0, + }), + dict({ + 'commandName': 'identify', + 'nparams': 0, + }), + dict({ + 'commandName': 'setName', + 'nparams': 1, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'ogp:Bridge', + 'states': list([ + dict({ + 'qualifiedName': 'core:AvailabilityState', + 'type': 'DiscreteState', + 'values': list([ + 'available', + 'unavailable', + ]), + }), + dict({ + 'qualifiedName': 'core:NameState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:RemovableState', + 'type': 'DataState', + }), + ]), + 'type': 'ACTUATOR', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'DynamicBridge', + }), + 'deviceURL': 'ogp://****-****-6867/039575E9', + 'enabled': True, + 'label': '** ** **', + 'lastUpdateTime': 1665238637000, + 'oid': '5cdf0023-2d7e-4e8e-bfb0-74ebb6ebe0eb', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'states': list([ + dict({ + 'name': 'core:NameState', + 'type': 3, + 'value': '** ** **', + }), + ]), + 'type': 1, + 'uiClass': 'ProtocolGateway', + 'widget': 'DynamicBridge', + }), + dict({ + 'attributes': list([ + dict({ + 'name': 'core:ManufacturerReference', + 'type': 3, + 'value': 'OGP Intesis Bridge', + }), + dict({ + 'name': 'ogp:Features', + 'type': 10, + 'value': list([ + dict({ + 'name': 'discovery', + }), + dict({ + 'name': 'identification', + }), + ]), + }), + dict({ + 'name': 'core:Manufacturer', + 'type': 3, + 'value': 'Overkiz', + }), + dict({ + 'name': 'core:Technology', + 'type': 3, + 'value': 'Intesis', + }), + ]), + 'available': True, + 'controllableName': 'ogp:Bridge', + 'creationTime': 1665238637000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'discover', + 'nparams': 0, + }), + dict({ + 'commandName': 'identify', + 'nparams': 0, + }), + dict({ + 'commandName': 'setName', + 'nparams': 1, + }), + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'ogp:Bridge', + 'states': list([ + dict({ + 'qualifiedName': 'core:AvailabilityState', + 'type': 'DiscreteState', + 'values': list([ + 'available', + 'unavailable', + ]), + }), + dict({ + 'qualifiedName': 'core:NameState', + 'type': 'DataState', + }), + dict({ + 'qualifiedName': 'core:RemovableState', + 'type': 'DataState', + }), + ]), + 'type': 'ACTUATOR', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'DynamicBridge', + }), + 'deviceURL': 'ogp://****-****-6867/09E45393', + 'enabled': True, + 'label': '** ** **', + 'lastUpdateTime': 1665238637000, + 'oid': 'b1f5dc24-058e-4cb2-a052-7b2d5a7de7a5', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'states': list([ + dict({ + 'name': 'core:NameState', + 'type': 3, + 'value': '** ** **', + }), + ]), + 'type': 1, + 'uiClass': 'ProtocolGateway', + 'widget': 'DynamicBridge', + }), + dict({ + 'attributes': list([ + dict({ + 'name': 'rts:diy', + 'type': 6, + 'value': True, + }), + ]), + 'available': True, + 'controllableName': 'rts:RollerShutterRTSComponent', + 'creationTime': 1667840384000, + 'definition': dict({ + 'commands': list([ + dict({ + 'commandName': 'close', + 'nparams': 1, + }), + dict({ + 'commandName': 'down', + 'nparams': 1, + }), + dict({ + 'commandName': 'identify', + 'nparams': 0, + }), + dict({ + 'commandName': 'my', + 'nparams': 1, + }), + dict({ + 'commandName': 'open', + 'nparams': 1, + }), + dict({ + 'commandName': 'rest', + 'nparams': 1, + }), + dict({ + 'commandName': 'stop', + 'nparams': 1, + }), + dict({ + 'commandName': 'test', + 'nparams': 0, + }), + dict({ + 'commandName': 'up', + 'nparams': 1, + }), + dict({ + 'commandName': 'openConfiguration', + 'nparams': 1, + }), + ]), + 'dataProperties': list([ + dict({ + 'qualifiedName': 'core:identifyInterval', + 'value': '0', + }), + ]), + 'qualifiedName': 'rts:RollerShutterRTSComponent', + 'states': list([ + ]), + 'type': 'ACTUATOR', + 'uiClass': 'RollerShutter', + 'uiProfiles': list([ + 'OpenCloseShutter', + 'OpenClose', + ]), + 'widgetName': 'UpDownRollerShutter', + }), + 'deviceURL': 'rts://****-****-6867/16756006', + 'enabled': True, + 'label': '** ** **', + 'lastUpdateTime': 1667840384000, + 'oid': '1a10d6f6-9bc3-40f3-a33c-e383fd41d3e8', + 'placeOID': '9e3d6899-50bb-4869-9c5e-46c2b57f7c9e', + 'shortcut': False, + 'type': 1, + 'uiClass': 'RollerShutter', + 'widget': 'UpDownRollerShutter', + }), + dict({ + 'available': True, + 'controllableName': 'zigbee:TransceiverV3_0Component', + 'creationTime': 1665238630000, + 'definition': dict({ + 'commands': list([ + ]), + 'dataProperties': list([ + ]), + 'qualifiedName': 'zigbee:TransceiverV3_0Component', + 'states': list([ + ]), + 'type': 'PROTOCOL_GATEWAY', + 'uiClass': 'ProtocolGateway', + 'uiProfiles': list([ + 'Specific', + ]), + 'widgetName': 'ZigbeeStack', + }), + 'deviceURL': 'zigbee://****-****-6867/65535', + 'enabled': True, + 'label': '** *(**)*', + 'lastUpdateTime': 1665238630000, + 'oid': '1629c223-d115-4aad-808a-373f428d9c27', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'shortcut': False, + 'type': 5, + 'uiClass': 'ProtocolGateway', + 'widget': 'ZigbeeStack', + }), + ]), + 'disconnectionConfiguration': dict({ + 'notificationText': 'Your Somfy box is disconnected', + 'notificationTitle': '[User] : Your Somfy box is disconnected', + 'notificationType': 'PUSH', + 'targetPushSubscriptions': list([ + '8849df9a-b61a-498f-ab81-67a767adba31', + ]), + }), + 'features': list([ + ]), + 'gateways': list([ + dict({ + 'alive': True, + 'autoUpdateEnabled': True, + 'connectivity': dict({ + 'protocolVersion': '2023.4.4', + 'status': 'OK', + }), + 'functions': 'INTERNET_AUTHORIZATION,SCENARIO_DOWNLOAD,SCENARIO_AUTO_LAUNCHING,SCENARIO_TELECO_LAUNCHING,INTERNET_UPLOAD,INTERNET_UPDATE,TRIGGERS_SENSORS', + 'gatewayId': '****-****-6867', + 'mode': 'ACTIVE', + 'placeOID': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'subType': 1, + 'syncInProgress': False, + 'timeReliable': True, + 'type': 98, + 'upToDate': True, + 'updateStatus': 'UP_TO_DATE', + }), + ]), + 'id': 'SETUP-****-****-6867', + 'lastUpdateTime': 1665238624000, + 'location': dict({ + 'addressLine1': '** **', + 'addressLine2': '*', + 'city': '** **', + 'country': '**', + 'countryCode': 'NL', + 'creationTime': 1665238624000, + 'dawnOffset': 0, + 'duskOffset': 0, + 'lastUpdateTime': 1667054735000, + 'latitude': '**', + 'longitude': '**', + 'postalCode': '** **', + 'summerSolsticeDuskMinutes': 1290, + 'timezone': 'Europe/Amsterdam', + 'twilightAngle': 'SOLAR', + 'twilightCity': 'amsterdam', + 'twilightMode': 2, + 'twilightOffsetEnabled': False, + 'winterSolsticeDuskMinutes': 990, + }), + 'oid': '15eaf55a-8af9-483b-ae4a-ffd4254fd762', + 'resellerDelegationType': 'NEVER', + 'rootPlace': dict({ + 'creationTime': 1665238624000, + 'label': '** **', + 'lastUpdateTime': 1665238630000, + 'oid': '41d63e43-bfa8-4e24-8c16-4faae0448cab', + 'subPlaces': list([ + dict({ + 'creationTime': 1667840432000, + 'label': '**', + 'lastUpdateTime': 1667840432000, + 'metadata': '{"color":"#08C27F"}', + 'oid': '9e3d6899-50bb-4869-9c5e-46c2b57f7c9e', + 'subPlaces': list([ + ]), + 'type': 108, + }), + ]), + 'type': 200, + }), + 'zones': list([ + ]), + }), + }) +# --- diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index a9d950a3a66..146d54feb9c 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -1,13 +1,14 @@ -"""Tests for Overkiz (by Somfy) config flow.""" +"""Tests for Overkiz config flow.""" from __future__ import annotations from ipaddress import ip_address from unittest.mock import AsyncMock, Mock, patch -from aiohttp import ClientError +from aiohttp import ClientConnectorCertificateError, ClientError from pyoverkiz.exceptions import ( BadCredentialsException, MaintenanceException, + NotSuchTokenException, TooManyAttemptsBannedException, TooManyRequestsException, UnknownUserException, @@ -28,14 +29,18 @@ TEST_EMAIL = "test@testdomain.com" TEST_EMAIL2 = "test@testdomain.nl" TEST_PASSWORD = "test-password" TEST_PASSWORD2 = "test-password2" -TEST_HUB = "somfy_europe" -TEST_HUB2 = "hi_kumo_europe" -TEST_HUB_COZYTOUCH = "atlantic_cozytouch" +TEST_SERVER = "somfy_europe" +TEST_SERVER2 = "hi_kumo_europe" +TEST_SERVER_COZYTOUCH = "atlantic_cozytouch" TEST_GATEWAY_ID = "1234-5678-9123" TEST_GATEWAY_ID2 = "4321-5678-9123" +TEST_GATEWAY_ID3 = "SOMFY_PROTECT-v0NT53occUBPyuJRzx59kalW1hFfzimN" + +TEST_HOST = "gateway-1234-5678-9123.local:8443" +TEST_HOST2 = "192.168.11.104:8443" MOCK_GATEWAY_RESPONSE = [Mock(id=TEST_GATEWAY_ID)] -MOCK_GATEWAY2_RESPONSE = [Mock(id=TEST_GATEWAY_ID2)] +MOCK_GATEWAY2_RESPONSE = [Mock(id=TEST_GATEWAY_ID3), Mock(id=TEST_GATEWAY_ID2)] FAKE_ZERO_CONF_INFO = ZeroconfServiceInfo( ip_address=ip_address("192.168.0.51"), @@ -51,31 +56,133 @@ FAKE_ZERO_CONF_INFO = ZeroconfServiceInfo( }, ) +FAKE_ZERO_CONF_INFO_LOCAL = ZeroconfServiceInfo( + ip_address=ip_address("192.168.0.51"), + ip_addresses=[ip_address("192.168.0.51")], + port=8443, + hostname=f"gateway-{TEST_GATEWAY_ID}.local.", + type="_kizboxdev._tcp.local.", + name=f"gateway-{TEST_GATEWAY_ID}._kizboxdev._tcp.local.", + properties={ + "api_version": "1", + "gateway_pin": TEST_GATEWAY_ID, + "fw_version": "2021.5.4-29", + }, +) -async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + +async def test_form_cloud(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == "form" - assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "cloud"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "cloud" with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( - "pyoverkiz.client.OverkizClient.get_gateways", return_value=None + "pyoverkiz.client.OverkizClient.get_gateways", + return_value=MOCK_GATEWAY_RESPONSE, ): - result2 = await hass.config_entries.flow.async_configure( + await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) - assert result2["type"] == "create_entry" - assert result2["title"] == TEST_EMAIL - assert result2["data"] == { - "username": TEST_EMAIL, - "password": TEST_PASSWORD, - "hub": TEST_HUB, - } + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_only_cloud_supported( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER2}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "cloud" + + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( + "pyoverkiz.client.OverkizClient.get_gateways", + return_value=MOCK_GATEWAY_RESPONSE, + ): + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": TEST_EMAIL, "password": TEST_PASSWORD}, + ) + + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_local_happy_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "local" + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + login=AsyncMock(return_value=True), + get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), + get_setup_option=AsyncMock(return_value=True), + generate_local_token=AsyncMock(return_value="1234123412341234"), + activate_local_token=AsyncMock(return_value=True), + ): + await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "host": "gateway-1234-5678-1234.local:8443", + }, + ) await hass.async_block_till_done() @@ -95,23 +202,149 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: (Exception, "unknown"), ], ) -async def test_form_invalid_auth( +async def test_form_invalid_auth_cloud( hass: HomeAssistant, side_effect: Exception, error: str ) -> None: - """Test we handle invalid auth.""" + """Test we handle invalid auth (cloud).""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "cloud"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "cloud" + with patch("pyoverkiz.client.OverkizClient.login", side_effect=side_effect): - result2 = await hass.config_entries.flow.async_configure( + result4 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) - assert result["step_id"] == config_entries.SOURCE_USER - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result2["errors"] == {"base": error} + await hass.async_block_till_done() + + assert result4["type"] == data_entry_flow.FlowResultType.FORM + assert result4["errors"] == {"base": error} + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (BadCredentialsException, "invalid_auth"), + (TooManyRequestsException, "too_many_requests"), + ( + ClientConnectorCertificateError(Mock(host=TEST_HOST), Exception), + "certificate_verify_failed", + ), + (TimeoutError, "cannot_connect"), + (ClientError, "cannot_connect"), + (MaintenanceException, "server_in_maintenance"), + (TooManyAttemptsBannedException, "too_many_attempts"), + (UnknownUserException, "unsupported_hardware"), + (NotSuchTokenException, "no_such_token"), + (Exception, "unknown"), + ], +) +async def test_form_invalid_auth_local( + hass: HomeAssistant, side_effect: Exception, error: str +) -> None: + """Test we handle invalid auth (local).""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "local" + + with patch("pyoverkiz.client.OverkizClient.login", side_effect=side_effect): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": TEST_HOST, + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "verify_ssl": True, + }, + ) + + await hass.async_block_till_done() + + assert result4["type"] == data_entry_flow.FlowResultType.FORM + assert result4["errors"] == {"base": error} + + +async def test_form_local_developer_mode_disabled( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "local" + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + login=AsyncMock(return_value=True), + get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), + get_setup_option=AsyncMock(return_value=None), + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "host": "gateway-1234-5678-1234.local:8443", + "verify_ssl": True, + }, + ) + + assert result4["type"] == data_entry_flow.FlowResultType.FORM + assert result4["errors"] == {"base": "developer_mode_disabled"} @pytest.mark.parametrize( @@ -123,79 +356,398 @@ async def test_form_invalid_auth( async def test_form_invalid_cozytouch_auth( hass: HomeAssistant, side_effect: Exception, error: str ) -> None: - """Test we handle invalid auth from CozyTouch.""" + """Test we handle invalid auth (cloud).""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER_COZYTOUCH}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "cloud" + with patch("pyoverkiz.client.OverkizClient.login", side_effect=side_effect): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": TEST_EMAIL, - "password": TEST_PASSWORD, - "hub": TEST_HUB_COZYTOUCH, - }, - ) - - assert result["step_id"] == config_entries.SOURCE_USER - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result2["errors"] == {"base": error} - - -async def test_abort_on_duplicate_entry(hass: HomeAssistant) -> None: - """Test config flow aborts Config Flow on duplicate entries.""" - MockConfigEntry( - domain=DOMAIN, - unique_id=TEST_GATEWAY_ID, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, - ).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( - "pyoverkiz.client.OverkizClient.get_gateways", - return_value=MOCK_GATEWAY_RESPONSE, - ): - result2 = await hass.config_entries.flow.async_configure( + result3 = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) - assert result2["type"] == data_entry_flow.FlowResultType.ABORT - assert result2["reason"] == "already_configured" + await hass.async_block_till_done() + + assert result3["type"] == data_entry_flow.FlowResultType.FORM + assert result3["errors"] == {"base": error} + assert result3["step_id"] == "cloud" -async def test_allow_multiple_unique_entries(hass: HomeAssistant) -> None: - """Test config flow allows Config Flow unique entries.""" +async def test_cloud_abort_on_duplicate_entry( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" + MockConfigEntry( domain=DOMAIN, - unique_id=TEST_GATEWAY_ID2, - data={"username": "test2@testdomain.com", "password": TEST_PASSWORD}, + unique_id=TEST_GATEWAY_ID, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER}, ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "cloud"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "cloud" + + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( + "pyoverkiz.client.OverkizClient.get_gateways", + return_value=MOCK_GATEWAY_RESPONSE, + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": TEST_EMAIL, "password": TEST_PASSWORD}, + ) + + assert result4["type"] == data_entry_flow.FlowResultType.ABORT + assert result4["reason"] == "already_configured" + + +async def test_local_abort_on_duplicate_entry( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" + + MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + data={ + "host": TEST_HOST, + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_SERVER, + }, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "local" + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + login=AsyncMock(return_value=True), + get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), + get_setup_option=AsyncMock(return_value=True), + generate_local_token=AsyncMock(return_value="1234123412341234"), + activate_local_token=AsyncMock(return_value=True), + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": TEST_HOST, + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "verify_ssl": True, + }, + ) + + assert result4["type"] == data_entry_flow.FlowResultType.ABORT + assert result4["reason"] == "already_configured" + + +async def test_cloud_allow_multiple_unique_entries( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" + + MockConfigEntry( + version=1, + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID2, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "cloud"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "cloud" + + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( + "pyoverkiz.client.OverkizClient.get_gateways", + return_value=MOCK_GATEWAY_RESPONSE, + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": TEST_EMAIL, "password": TEST_PASSWORD}, + ) + + assert result4["type"] == "create_entry" + assert result4["title"] == TEST_EMAIL + assert result4["data"] == { + "api_type": "cloud", + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_SERVER, + } + + +async def test_cloud_reauth_success(hass: HomeAssistant) -> None: + """Test reauthentication flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + version=2, + data={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_SERVER2, + "api_type": "cloud", + }, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "cloud" + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( "pyoverkiz.client.OverkizClient.get_gateways", return_value=MOCK_GATEWAY_RESPONSE, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + user_input={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD2, + }, ) - assert result2["type"] == "create_entry" - assert result2["title"] == TEST_EMAIL - assert result2["data"] == { - "username": TEST_EMAIL, - "password": TEST_PASSWORD, - "hub": TEST_HUB, - } + assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert mock_entry.data["username"] == TEST_EMAIL + assert mock_entry.data["password"] == TEST_PASSWORD2 + + +async def test_cloud_reauth_wrong_account(hass: HomeAssistant) -> None: + """Test reauthentication flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + version=2, + data={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_SERVER2, + "api_type": "cloud", + }, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "cloud" + + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( + "pyoverkiz.client.OverkizClient.get_gateways", + return_value=MOCK_GATEWAY2_RESPONSE, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD2, + }, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["reason"] == "reauth_wrong_account" + + +async def test_local_reauth_success(hass: HomeAssistant) -> None: + """Test reauthentication flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + version=2, + data={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_SERVER, + "host": TEST_HOST, + "api_type": "local", + }, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result2["step_id"] == "local" + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + login=AsyncMock(return_value=True), + get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), + get_setup_option=AsyncMock(return_value=True), + generate_local_token=AsyncMock(return_value="1234123412341234"), + activate_local_token=AsyncMock(return_value=True), + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD2, + }, + ) + + assert result3["type"] == data_entry_flow.FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert mock_entry.data["username"] == TEST_EMAIL + assert mock_entry.data["password"] == TEST_PASSWORD2 + + +async def test_local_reauth_wrong_account(hass: HomeAssistant) -> None: + """Test reauthentication flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID2, + version=2, + data={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_SERVER, + "host": TEST_HOST, + "api_type": "local", + }, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result2["step_id"] == "local" + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + login=AsyncMock(return_value=True), + get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), + get_setup_option=AsyncMock(return_value=True), + generate_local_token=AsyncMock(return_value="1234123412341234"), + activate_local_token=AsyncMock(return_value=True), + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD2, + }, + ) + + assert result3["type"] == data_entry_flow.FlowResultType.ABORT + assert result3["reason"] == "reauth_wrong_account" async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: @@ -213,20 +765,37 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "cloud"}, + ) + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( "pyoverkiz.client.OverkizClient.get_gateways", return_value=None ): - result2 = await hass.config_entries.flow.async_configure( + result4 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + { + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + }, ) - assert result2["type"] == "create_entry" - assert result2["title"] == TEST_EMAIL - assert result2["data"] == { + assert result4["type"] == "create_entry" + assert result4["title"] == TEST_EMAIL + assert result4["data"] == { "username": TEST_EMAIL, "password": TEST_PASSWORD, - "hub": TEST_HUB, + "hub": TEST_SERVER, + "api_type": "cloud", } assert len(mock_setup_entry.mock_calls) == 1 @@ -237,7 +806,7 @@ async def test_dhcp_flow_already_configured(hass: HomeAssistant) -> None: config_entry = MockConfigEntry( domain=DOMAIN, unique_id=TEST_GATEWAY_ID, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER}, ) config_entry.add_to_hass(hass) @@ -266,20 +835,95 @@ async def test_zeroconf_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) - assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "cloud"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "cloud" + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( - "pyoverkiz.client.OverkizClient.get_gateways", return_value=None + "pyoverkiz.client.OverkizClient.get_gateways", + return_value=MOCK_GATEWAY_RESPONSE, ): - result2 = await hass.config_entries.flow.async_configure( + result4 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) - assert result2["type"] == "create_entry" - assert result2["title"] == TEST_EMAIL - assert result2["data"] == { + assert result4["type"] == "create_entry" + assert result4["title"] == TEST_EMAIL + assert result4["data"] == { "username": TEST_EMAIL, "password": TEST_PASSWORD, - "hub": TEST_HUB, + "hub": TEST_SERVER, + "api_type": "cloud", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_local_zeroconf_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test that zeroconf discovery for new local bridge works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=FAKE_ZERO_CONF_INFO_LOCAL, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == config_entries.SOURCE_USER + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "local" + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + login=AsyncMock(return_value=True), + get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), + get_setup_option=AsyncMock(return_value=True), + generate_local_token=AsyncMock(return_value="1234123412341234"), + activate_local_token=AsyncMock(return_value=True), + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": TEST_EMAIL, "password": TEST_PASSWORD, "verify_ssl": False}, + ) + + assert result4["type"] == "create_entry" + assert result4["title"] == "gateway-1234-5678-9123.local:8443" + assert result4["data"] == { + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_SERVER, + "host": "gateway-1234-5678-9123.local:8443", + "api_type": "local", + "token": "1234123412341234", + "verify_ssl": False, } assert len(mock_setup_entry.mock_calls) == 1 @@ -290,7 +934,7 @@ async def test_zeroconf_flow_already_configured(hass: HomeAssistant) -> None: config_entry = MockConfigEntry( domain=DOMAIN, unique_id=TEST_GATEWAY_ID, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER}, ) config_entry.add_to_hass(hass) @@ -302,85 +946,3 @@ async def test_zeroconf_flow_already_configured(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "already_configured" - - -async def test_reauth_success(hass: HomeAssistant) -> None: - """Test reauthentication flow.""" - - mock_entry = MockConfigEntry( - domain=DOMAIN, - unique_id=TEST_GATEWAY_ID, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB2}, - ) - mock_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_REAUTH, - "unique_id": mock_entry.unique_id, - "entry_id": mock_entry.entry_id, - }, - data=mock_entry.data, - ) - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - - with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( - "pyoverkiz.client.OverkizClient.get_gateways", - return_value=MOCK_GATEWAY_RESPONSE, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - "username": TEST_EMAIL, - "password": TEST_PASSWORD2, - "hub": TEST_HUB2, - }, - ) - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - assert mock_entry.data["username"] == TEST_EMAIL - assert mock_entry.data["password"] == TEST_PASSWORD2 - - -async def test_reauth_wrong_account(hass: HomeAssistant) -> None: - """Test reauthentication flow.""" - - mock_entry = MockConfigEntry( - domain=DOMAIN, - unique_id=TEST_GATEWAY_ID, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB2}, - ) - mock_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_REAUTH, - "unique_id": mock_entry.unique_id, - "entry_id": mock_entry.entry_id, - }, - data=mock_entry.data, - ) - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - - with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( - "pyoverkiz.client.OverkizClient.get_gateways", - return_value=MOCK_GATEWAY2_RESPONSE, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - "username": TEST_EMAIL, - "password": TEST_PASSWORD2, - "hub": TEST_HUB2, - }, - ) - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "reauth_wrong_account" diff --git a/tests/components/overkiz/test_diagnostics.py b/tests/components/overkiz/test_diagnostics.py new file mode 100644 index 00000000000..6d0498c237b --- /dev/null +++ b/tests/components/overkiz/test_diagnostics.py @@ -0,0 +1,63 @@ +"""Tests for the diagnostics data provided by the Overkiz integration.""" +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.overkiz.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry, load_json_object_fixture +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + diagnostic_data = load_json_object_fixture("overkiz/setup_tahoma_switch.json") + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + get_diagnostic_data=AsyncMock(return_value=diagnostic_data), + get_execution_history=AsyncMock(return_value=[]), + ): + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) + + +async def test_device_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device diagnostics.""" + diagnostic_data = load_json_object_fixture("overkiz/setup_tahoma_switch.json") + + device = device_registry.async_get_device( + identifiers={(DOMAIN, "rts://****-****-6867/16756006")} + ) + assert device is not None + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + get_diagnostic_data=AsyncMock(return_value=diagnostic_data), + get_execution_history=AsyncMock(return_value=[]), + ): + assert ( + await get_diagnostics_for_device( + hass, hass_client, init_integration, device + ) + == snapshot + ) diff --git a/tests/components/overkiz/test_init.py b/tests/components/overkiz/test_init.py index 774f3c9a79a..ddecee7c167 100644 --- a/tests/components/overkiz/test_init.py +++ b/tests/components/overkiz/test_init.py @@ -4,7 +4,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .test_config_flow import TEST_EMAIL, TEST_GATEWAY_ID, TEST_HUB, TEST_PASSWORD +from .test_config_flow import TEST_EMAIL, TEST_GATEWAY_ID, TEST_PASSWORD, TEST_SERVER from tests.common import MockConfigEntry, mock_registry @@ -23,7 +23,7 @@ async def test_unique_id_migration(hass: HomeAssistant) -> None: mock_entry = MockConfigEntry( domain=DOMAIN, unique_id=TEST_GATEWAY_ID, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER}, ) mock_entry.add_to_hass(hass) diff --git a/tests/components/p1_monitor/test_diagnostics.py b/tests/components/p1_monitor/test_diagnostics.py index 47f43dd3401..55d4ccc5e67 100644 --- a/tests/components/p1_monitor/test_diagnostics.py +++ b/tests/components/p1_monitor/test_diagnostics.py @@ -35,12 +35,12 @@ async def test_diagnostics( "energy_production_low": 1432.279, }, "phases": { - "voltage_phase_l1": "233.6", - "voltage_phase_l2": "0.0", - "voltage_phase_l3": "233.0", - "current_phase_l1": "1.6", - "current_phase_l2": "4.44", - "current_phase_l3": "3.51", + "voltage_phase_l1": 233.6, + "voltage_phase_l2": 0.0, + "voltage_phase_l3": 233.0, + "current_phase_l1": 1.6, + "current_phase_l2": 4.44, + "current_phase_l3": 3.51, "power_consumed_phase_l1": 315, "power_consumed_phase_l2": 0, "power_consumed_phase_l3": 624, @@ -49,11 +49,11 @@ async def test_diagnostics( "power_produced_phase_l3": 0, }, "settings": { - "gas_consumption_price": "0.64", - "energy_consumption_price_high": "0.20522", - "energy_consumption_price_low": "0.20522", - "energy_production_price_high": "0.20522", - "energy_production_price_low": "0.20522", + "gas_consumption_price": 0.64, + "energy_consumption_price_high": 0.20522, + "energy_consumption_price_low": 0.20522, + "energy_production_price_high": 0.20522, + "energy_production_price_low": 0.20522, }, "watermeter": { "consumption_day": 112.0, diff --git a/tests/components/peco/test_config_flow.py b/tests/components/peco/test_config_flow.py index 532450f0099..ca6759baeff 100644 --- a/tests/components/peco/test_config_flow.py +++ b/tests/components/peco/test_config_flow.py @@ -1,6 +1,7 @@ """Test the PECO Outage Counter config flow.""" from unittest.mock import patch +from peco import HttpError, IncompatibleMeterError, UnresponsiveMeterError import pytest from voluptuous.error import MultipleInvalid @@ -17,6 +18,7 @@ async def test_form(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.FORM assert result["errors"] is None + assert result["step_id"] == "user" with patch( "homeassistant.components.peco.async_setup_entry", @@ -35,6 +37,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["data"] == { "county": "PHILADELPHIA", } + assert result2["context"]["unique_id"] == "PHILADELPHIA" async def test_invalid_county(hass: HomeAssistant) -> None: @@ -43,37 +46,160 @@ async def test_invalid_county(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == FlowResultType.FORM - assert result["errors"] is None - - with pytest.raises(MultipleInvalid): - await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "county": "INVALID_COUNTY_THAT_SHOULD_NOT_EXIST", - }, - ) - await hass.async_block_till_done() - - second_result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert second_result["type"] == FlowResultType.FORM - assert second_result["errors"] is None + assert result["step_id"] == "user" with patch( "homeassistant.components.peco.async_setup_entry", return_value=True, - ): - second_result2 = await hass.config_entries.flow.async_configure( - second_result["flow_id"], + ), pytest.raises(MultipleInvalid): + await hass.config_entries.flow.async_configure( + result["flow_id"], { - "county": "PHILADELPHIA", + "county": "INVALID_COUNTY_THAT_SHOULDNT_EXIST", }, ) await hass.async_block_till_done() - assert second_result2["type"] == FlowResultType.CREATE_ENTRY - assert second_result2["title"] == "Philadelphia Outage Count" - assert second_result2["data"] == { - "county": "PHILADELPHIA", - } + +async def test_meter_value_error(hass: HomeAssistant) -> None: + """Test if the MeterValueError error works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "county": "PHILADELPHIA", + "phone_number": "INVALID_SMART_METER_THAT_SHOULD_NOT_EXIST", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + assert result["progress_action"] == "verifying_meter" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"phone_number": "invalid_phone_number"} + + +async def test_incompatible_meter_error(hass: HomeAssistant) -> None: + """Test if the IncompatibleMeter error works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("peco.PecoOutageApi.meter_check", side_effect=IncompatibleMeterError()): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "county": "PHILADELPHIA", + "phone_number": "1234567890", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + assert result["progress_action"] == "verifying_meter" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "incompatible_meter" + + +async def test_unresponsive_meter_error(hass: HomeAssistant) -> None: + """Test if the UnresponsiveMeter error works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("peco.PecoOutageApi.meter_check", side_effect=UnresponsiveMeterError()): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "county": "PHILADELPHIA", + "phone_number": "1234567890", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + assert result["progress_action"] == "verifying_meter" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"phone_number": "unresponsive_meter"} + + +async def test_meter_http_error(hass: HomeAssistant) -> None: + """Test if the InvalidMeter error works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("peco.PecoOutageApi.meter_check", side_effect=HttpError): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "county": "PHILADELPHIA", + "phone_number": "1234567890", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + assert result["progress_action"] == "verifying_meter" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"phone_number": "http_error"} + + +async def test_smart_meter(hass: HomeAssistant) -> None: + """Test if the Smart Meter step works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("peco.PecoOutageApi.meter_check", return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "county": "PHILADELPHIA", + "phone_number": "1234567890", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + assert result["progress_action"] == "verifying_meter" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Philadelphia - 1234567890" + assert result["data"]["phone_number"] == "1234567890" + assert result["context"]["unique_id"] == "PHILADELPHIA-1234567890" diff --git a/tests/components/peco/test_init.py b/tests/components/peco/test_init.py index 52a7ddd3b25..2919e508c97 100644 --- a/tests/components/peco/test_init.py +++ b/tests/components/peco/test_init.py @@ -2,7 +2,13 @@ import asyncio from unittest.mock import patch -from peco import AlertResults, BadJSONError, HttpError, OutageResults +from peco import ( + AlertResults, + BadJSONError, + HttpError, + OutageResults, + UnresponsiveMeterError, +) import pytest from homeassistant.components.peco.const import DOMAIN @@ -14,6 +20,7 @@ from tests.common import MockConfigEntry MOCK_ENTRY_DATA = {"county": "TOTAL"} COUNTY_ENTRY_DATA = {"county": "BUCKS"} INVALID_COUNTY_DATA = {"county": "INVALID"} +METER_DATA = {"county": "BUCKS", "phone_number": "1234567890"} async def test_unload_entry(hass: HomeAssistant) -> None: @@ -149,3 +156,154 @@ async def test_bad_json(hass: HomeAssistant, sensor: str) -> None: assert hass.states.get(f"sensor.{sensor}") is None assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_unresponsive_meter_error(hass: HomeAssistant) -> None: + """Test if it raises an error when the meter will not respond.""" + + config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA) + config_entry.add_to_hass(hass) + + with patch( + "peco.PecoOutageApi.meter_check", + side_effect=UnresponsiveMeterError(), + ), patch( + "peco.PecoOutageApi.get_outage_count", + return_value=OutageResults( + customers_out=0, + percent_customers_out=0, + outage_count=0, + customers_served=350394, + ), + ), patch( + "peco.PecoOutageApi.get_map_alerts", + return_value=AlertResults( + alert_content="Testing 1234", alert_title="Testing 4321" + ), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.meter_status") is None + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_meter_http_error(hass: HomeAssistant) -> None: + """Test if it raises an error when there is an HTTP error.""" + + config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA) + config_entry.add_to_hass(hass) + + with patch( + "peco.PecoOutageApi.meter_check", + side_effect=HttpError(), + ), patch( + "peco.PecoOutageApi.get_outage_count", + return_value=OutageResults( + customers_out=0, + percent_customers_out=0, + outage_count=0, + customers_served=350394, + ), + ), patch( + "peco.PecoOutageApi.get_map_alerts", + return_value=AlertResults( + alert_content="Testing 1234", alert_title="Testing 4321" + ), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.meter_status") is None + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_meter_bad_json(hass: HomeAssistant) -> None: + """Test if it raises an error when there is bad JSON.""" + + config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA) + config_entry.add_to_hass(hass) + + with patch( + "peco.PecoOutageApi.meter_check", + side_effect=BadJSONError(), + ), patch( + "peco.PecoOutageApi.get_outage_count", + return_value=OutageResults( + customers_out=0, + percent_customers_out=0, + outage_count=0, + customers_served=350394, + ), + ), patch( + "peco.PecoOutageApi.get_map_alerts", + return_value=AlertResults( + alert_content="Testing 1234", alert_title="Testing 4321" + ), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.meter_status") is None + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_meter_timeout(hass: HomeAssistant) -> None: + """Test if it raises an error when there is a timeout.""" + + config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA) + config_entry.add_to_hass(hass) + + with patch( + "peco.PecoOutageApi.meter_check", + side_effect=asyncio.TimeoutError(), + ), patch( + "peco.PecoOutageApi.get_outage_count", + return_value=OutageResults( + customers_out=0, + percent_customers_out=0, + outage_count=0, + customers_served=350394, + ), + ), patch( + "peco.PecoOutageApi.get_map_alerts", + return_value=AlertResults( + alert_content="Testing 1234", alert_title="Testing 4321" + ), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.meter_status") is None + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_meter_data(hass: HomeAssistant) -> None: + """Test if the meter returns the value successfully.""" + + config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA) + config_entry.add_to_hass(hass) + + with patch( + "peco.PecoOutageApi.meter_check", + return_value=True, + ), patch( + "peco.PecoOutageApi.get_outage_count", + return_value=OutageResults( + customers_out=0, + percent_customers_out=0, + outage_count=0, + customers_served=350394, + ), + ), patch( + "peco.PecoOutageApi.get_map_alerts", + return_value=AlertResults( + alert_content="Testing 1234", alert_title="Testing 4321" + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.meter_status") is not None + assert hass.states.get("binary_sensor.meter_status").state == "on" + assert config_entry.state == ConfigEntryState.LOADED diff --git a/tests/components/permobil/__init__.py b/tests/components/permobil/__init__.py new file mode 100644 index 00000000000..56e779eef4d --- /dev/null +++ b/tests/components/permobil/__init__.py @@ -0,0 +1 @@ +"""Tests for the MyPermobil integration.""" diff --git a/tests/components/permobil/conftest.py b/tests/components/permobil/conftest.py new file mode 100644 index 00000000000..2dcf9bd5ad2 --- /dev/null +++ b/tests/components/permobil/conftest.py @@ -0,0 +1,27 @@ +"""Common fixtures for the MyPermobil tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch + +from mypermobil import MyPermobil +import pytest + +from .const import MOCK_REGION_NAME, MOCK_TOKEN, MOCK_URL + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.permobil.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def my_permobil() -> Mock: + """Mock spec for MyPermobilApi.""" + mock = Mock(spec=MyPermobil) + mock.request_region_names.return_value = {MOCK_REGION_NAME: MOCK_URL} + mock.request_application_token.return_value = MOCK_TOKEN + mock.region = "" + return mock diff --git a/tests/components/permobil/const.py b/tests/components/permobil/const.py new file mode 100644 index 00000000000..cb8a0c32f17 --- /dev/null +++ b/tests/components/permobil/const.py @@ -0,0 +1,5 @@ +"""Test constants for Permobil.""" + +MOCK_URL = "https://example.com" +MOCK_REGION_NAME = "region_name" +MOCK_TOKEN = ("a" * 256, "date") diff --git a/tests/components/permobil/test_config_flow.py b/tests/components/permobil/test_config_flow.py new file mode 100644 index 00000000000..ad61ead7bfc --- /dev/null +++ b/tests/components/permobil/test_config_flow.py @@ -0,0 +1,288 @@ +"""Test the MyPermobil config flow.""" +from unittest.mock import Mock, patch + +from mypermobil import MyPermobilAPIException, MyPermobilClientException +import pytest + +from homeassistant import config_entries +from homeassistant.components.permobil import config_flow +from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_REGION, CONF_TOKEN, CONF_TTL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import MOCK_REGION_NAME, MOCK_TOKEN, MOCK_URL + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + +MOCK_CODE = "012345" +MOCK_EMAIL = "valid@email.com" +INVALID_EMAIL = "this is not a valid email" +VALID_DATA = { + CONF_EMAIL: MOCK_EMAIL, + CONF_REGION: MOCK_URL, + CONF_CODE: MOCK_CODE, + CONF_TOKEN: MOCK_TOKEN[0], + CONF_TTL: MOCK_TOKEN[1], +} + + +async def test_sucessful_config_flow(hass: HomeAssistant, my_permobil: Mock) -> None: + """Test the config flow from start to finish with no errors.""" + # init flow + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_EMAIL: MOCK_EMAIL}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "region" + assert result["errors"] == {} + + # select region step + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_REGION: MOCK_REGION_NAME}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "email_code" + assert result["errors"] == {} + # request region code + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: MOCK_CODE}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == VALID_DATA + + +async def test_config_flow_incorrect_code( + hass: HomeAssistant, my_permobil: Mock +) -> None: + """Test the config flow from start to until email code verification and have the API return error.""" + my_permobil.request_application_token.side_effect = MyPermobilAPIException + # init flow + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_EMAIL: MOCK_EMAIL}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "region" + assert result["errors"] == {} + + # select region step + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_REGION: MOCK_REGION_NAME}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "email_code" + assert result["errors"] == {} + + # request region code + # here the request_application_token raises a MyPermobilAPIException + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: MOCK_CODE}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "email_code" + assert result["errors"]["base"] == "invalid_code" + + +async def test_config_flow_incorrect_region( + hass: HomeAssistant, my_permobil: Mock +) -> None: + """Test the config flow from start to until the request for email code and have the API return error.""" + my_permobil.request_application_code.side_effect = MyPermobilAPIException + # init flow + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_EMAIL: MOCK_EMAIL}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "region" + assert result["errors"] == {} + + # select region step + # here the request_application_code raises a MyPermobilAPIException + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_REGION: MOCK_REGION_NAME}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "region" + assert result["errors"]["base"] == "code_request_error" + + +async def test_config_flow_region_request_error( + hass: HomeAssistant, my_permobil: Mock +) -> None: + """Test the config flow from start to until the request for regions and have the API return error.""" + my_permobil.request_region_names.side_effect = MyPermobilAPIException + # init flow + # here the request_region_names raises a MyPermobilAPIException + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_EMAIL: MOCK_EMAIL}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "region" + assert result["errors"]["base"] == "region_fetch_error" + + +async def test_config_flow_invalid_email( + hass: HomeAssistant, my_permobil: Mock +) -> None: + """Test the config flow from start to until the request for regions and have the API return error.""" + my_permobil.set_email.side_effect = MyPermobilClientException() + # init flow + # here the set_email raises a MyPermobilClientException + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_EMAIL: INVALID_EMAIL}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == config_entries.SOURCE_USER + assert result["errors"]["base"] == "invalid_email" + + +async def test_config_flow_reauth_success( + hass: HomeAssistant, my_permobil: Mock +) -> None: + """Test the config flow reauth make sure that the values are replaced.""" + # new token and code + reauth_token = ("b" * 256, "reauth_date") + reauth_code = "567890" + my_permobil.request_application_token.return_value = reauth_token + + mock_entry = MockConfigEntry( + domain="permobil", + data=VALID_DATA, + ) + mock_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "reauth", "entry_id": mock_entry.entry_id}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "email_code" + assert result["errors"] == {} + + # request request new token + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: reauth_code}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_EMAIL: MOCK_EMAIL, + CONF_REGION: MOCK_URL, + CONF_CODE: reauth_code, + CONF_TOKEN: reauth_token[0], + CONF_TTL: reauth_token[1], + } + + +async def test_config_flow_reauth_fail_invalid_code( + hass: HomeAssistant, my_permobil: Mock +) -> None: + """Test the config flow reauth when the email code fails.""" + # new code + reauth_invalid_code = "567890" # pretend this code is invalid/incorrect + my_permobil.request_application_token.side_effect = MyPermobilAPIException + mock_entry = MockConfigEntry( + domain="permobil", + data=VALID_DATA, + ) + mock_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "reauth", "entry_id": mock_entry.entry_id}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "email_code" + assert result["errors"] == {} + + # request request new token but have the API return error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: reauth_invalid_code}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "email_code" + assert result["errors"]["base"] == "invalid_code" + + +async def test_config_flow_reauth_fail_code_request( + hass: HomeAssistant, my_permobil: Mock +) -> None: + """Test the config flow reauth.""" + my_permobil.request_application_code.side_effect = MyPermobilAPIException + mock_entry = MockConfigEntry( + domain="permobil", + data=VALID_DATA, + ) + mock_entry.add_to_hass(hass) + # test the reauth and have request_application_code fail leading to an abort + my_permobil.request_application_code.side_effect = MyPermobilAPIException + reauth_entry = hass.config_entries.async_entries(config_flow.DOMAIN)[0] + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "reauth", "entry_id": reauth_entry.entry_id}, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index 71491ee3caf..4d7781a095f 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -1,4 +1,6 @@ """The tests for the person component.""" +from collections.abc import Callable +from http import HTTPStatus from typing import Any from unittest.mock import patch @@ -29,7 +31,8 @@ from homeassistant.setup import async_setup_component from .conftest import DEVICE_TRACKER, DEVICE_TRACKER_2 from tests.common import MockUser, mock_component, mock_restore_cache -from tests.typing import WebSocketGenerator +from tests.test_util import mock_real_ip +from tests.typing import ClientSessionGenerator, WebSocketGenerator async def test_minimal_setup(hass: HomeAssistant) -> None: @@ -847,3 +850,63 @@ async def test_entities_in_person(hass: HomeAssistant) -> None: "device_tracker.paulus_iphone", "device_tracker.paulus_ipad", ] + + +@pytest.mark.parametrize( + ("ip", "status_code", "expected_fn"), + [ + ( + "192.168.0.10", + HTTPStatus.OK, + lambda user: { + user["user_id"]: {"name": user["name"], "picture": user["picture"]} + }, + ), + ( + "::ffff:192.168.0.10", + HTTPStatus.OK, + lambda user: { + user["user_id"]: {"name": user["name"], "picture": user["picture"]} + }, + ), + ( + "1.2.3.4", + HTTPStatus.BAD_REQUEST, + lambda _: {"code": "not_local", "message": "Not local"}, + ), + ( + "2001:db8::1", + HTTPStatus.BAD_REQUEST, + lambda _: {"code": "not_local", "message": "Not local"}, + ), + ], +) +async def test_list_persons( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + hass_admin_user: MockUser, + ip: str, + status_code: HTTPStatus, + expected_fn: Callable[[dict[str, Any]], dict[str, Any]], +) -> None: + """Test listing persons from a not local ip address.""" + + user_id = hass_admin_user.id + admin = {"id": "1234", "name": "Admin", "user_id": user_id, "picture": "/bla"} + config = { + DOMAIN: [ + admin, + {"id": "5678", "name": "Only a person"}, + ] + } + assert await async_setup_component(hass, DOMAIN, config) + + await async_setup_component(hass, "api", {}) + mock_real_ip(hass.http.app)(ip) + client = await hass_client_no_auth() + + resp = await client.get("/api/person/list") + + assert resp.status == status_code + result = await resp.json() + assert result == expected_fn(admin) diff --git a/tests/components/philips_js/__init__.py b/tests/components/philips_js/__init__.py index f524a586fc8..60e8b238917 100644 --- a/tests/components/philips_js/__init__.py +++ b/tests/components/philips_js/__init__.py @@ -73,3 +73,129 @@ MOCK_CONFIG_PAIRED = { } MOCK_ENTITY_ID = "media_player.philips_tv" + +MOCK_RECORDINGS_LIST = { + "version": "253.91", + "recordings": [ + { + "RecordingId": 36, + "RecordingType": "RECORDING_ONGOING", + "IsIpEpgRec": False, + "ccid": 2091, + "StartTime": 1676833531, + "Duration": 569, + "MarginStart": 0, + "MarginEnd": 0, + "EventId": 47369, + "EITVersion": 0, + "RetentionInfo": 0, + "EventInfo": "This is a event info which is not rejected by codespell.", + "EventExtendedInfo": "", + "EventGenre": "8", + "RecName": "Terra X", + "SeriesID": "None", + "SeasonNo": 0, + "EpisodeNo": 0, + "EpisodeCount": 72300, + "ProgramNumber": 11110, + "EventRating": 0, + "hasDot": True, + "isFTARecording": False, + "LastPinChangedTime": 0, + "Version": 344, + "HasCicamPin": False, + "HasLicenseFile": False, + "Size": 0, + "ResumeInfo": 0, + "IsPartial": False, + "AutoMarginStart": 0, + "AutoMarginEnd": 0, + "ServerRecordingId": -1, + "ActualStartTime": 1676833531, + "ProgramDuration": 0, + "IsRadio": False, + "EITSource": "EIT_SOURCE_PF", + "RecError": "REC_ERROR_NONE", + }, + { + "RecordingId": 35, + "RecordingType": "RECORDING_NEW", + "IsIpEpgRec": False, + "ccid": 2091, + "StartTime": 1676832212, + "Duration": 22, + "MarginStart": 0, + "MarginEnd": 0, + "EventId": 47369, + "EITVersion": 0, + "RetentionInfo": -1, + "EventInfo": "This is another event info which is not rejected by codespell.", + "EventExtendedInfo": "", + "EventGenre": "8", + "RecName": "Terra X", + "SeriesID": "None", + "SeasonNo": 0, + "EpisodeNo": 0, + "EpisodeCount": 70980, + "ProgramNumber": 11110, + "EventRating": 0, + "hasDot": True, + "isFTARecording": False, + "LastPinChangedTime": 0, + "Version": 339, + "HasCicamPin": False, + "HasLicenseFile": False, + "Size": 0, + "ResumeInfo": 0, + "IsPartial": False, + "AutoMarginStart": 0, + "AutoMarginEnd": 0, + "ServerRecordingId": -1, + "ActualStartTime": 1676832212, + "ProgramDuration": 0, + "IsRadio": False, + "EITSource": "EIT_SOURCE_PF", + "RecError": "REC_ERROR_NONE", + }, + { + "RecordingId": 34, + "RecordingType": "RECORDING_PARTIALLY_VIEWED", + "IsIpEpgRec": False, + "ccid": 2091, + "StartTime": 1676677580, + "Duration": 484, + "MarginStart": 0, + "MarginEnd": 0, + "EventId": -1, + "EITVersion": 0, + "RetentionInfo": -1, + "EventInfo": "\n\nAlpine Ski-WM: Parallel-Event, Übertragung aus Méribel/Frankreich\n\n14:10: Biathlon-WM (AD): 20 km Einzel Männer, Übertragung aus Oberhof\nHD-Produktion", + "EventExtendedInfo": "", + "EventGenre": "4", + "RecName": "ZDF HD 2023-02-18 00:46", + "SeriesID": "None", + "SeasonNo": 0, + "EpisodeNo": 0, + "EpisodeCount": 2760, + "ProgramNumber": 11110, + "EventRating": 0, + "hasDot": True, + "isFTARecording": False, + "LastPinChangedTime": 0, + "Version": 328, + "HasCicamPin": False, + "HasLicenseFile": False, + "Size": 0, + "ResumeInfo": 56, + "IsPartial": False, + "AutoMarginStart": 0, + "AutoMarginEnd": 0, + "ServerRecordingId": -1, + "ActualStartTime": 1676677581, + "ProgramDuration": 0, + "IsRadio": False, + "EITSource": "EIT_SOURCE_PF", + "RecError": "REC_ERROR_NONE", + }, + ], +} diff --git a/tests/components/philips_js/test_binary_sensor.py b/tests/components/philips_js/test_binary_sensor.py new file mode 100644 index 00000000000..01233706d07 --- /dev/null +++ b/tests/components/philips_js/test_binary_sensor.py @@ -0,0 +1,83 @@ +"""The tests for philips_js binary_sensor.""" +import pytest + +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from . import MOCK_NAME, MOCK_RECORDINGS_LIST + +ID_RECORDING_AVAILABLE = ( + "binary_sensor." + MOCK_NAME.replace(" ", "_").lower() + "_new_recording_available" +) +ID_RECORDING_ONGOING = ( + "binary_sensor." + MOCK_NAME.replace(" ", "_").lower() + "_recording_ongoing" +) + + +@pytest.fixture +async def mock_tv_api_invalid(mock_tv): + """Set up a invalid mock_tv with should not create sensors.""" + mock_tv.secured_transport = True + mock_tv.api_version = 1 + mock_tv.recordings_list = None + return mock_tv + + +@pytest.fixture +async def mock_tv_api_valid(mock_tv): + """Set up a valid mock_tv with should create sensors.""" + mock_tv.secured_transport = True + mock_tv.api_version = 6 + mock_tv.recordings_list = MOCK_RECORDINGS_LIST + return mock_tv + + +@pytest.fixture +async def mock_tv_recordings_list_unavailable(mock_tv): + """Set up a valid mock_tv with should create sensors.""" + mock_tv.secured_transport = True + mock_tv.api_version = 6 + mock_tv.recordings_list = None + return mock_tv + + +async def test_recordings_list_api_invalid( + mock_tv_api_invalid, mock_config_entry, hass: HomeAssistant +) -> None: + """Test if sensors are not created if mock_tv is invalid.""" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + state = hass.states.get(ID_RECORDING_AVAILABLE) + assert state is None + + state = hass.states.get(ID_RECORDING_ONGOING) + assert state is None + + +async def test_recordings_list_valid( + mock_tv_api_valid, mock_config_entry, hass: HomeAssistant +) -> None: + """Test if sensors are created correctly.""" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + state = hass.states.get(ID_RECORDING_AVAILABLE) + assert state.state == STATE_ON + + state = hass.states.get(ID_RECORDING_ONGOING) + assert state.state == STATE_ON + + +async def test_recordings_list_unavailable( + mock_tv_recordings_list_unavailable, mock_config_entry, hass: HomeAssistant +) -> None: + """Test if sensors are created correctly.""" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + state = hass.states.get(ID_RECORDING_AVAILABLE) + assert state.state == STATE_OFF + + state = hass.states.get(ID_RECORDING_ONGOING) + assert state.state == STATE_OFF diff --git a/tests/components/picnic/conftest.py b/tests/components/picnic/conftest.py new file mode 100644 index 00000000000..1ca6413fc42 --- /dev/null +++ b/tests/components/picnic/conftest.py @@ -0,0 +1,79 @@ +"""Conftest for Picnic tests.""" +from collections.abc import Awaitable, Callable +import json +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.picnic import CONF_COUNTRY_CODE, DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture +from tests.typing import WebSocketGenerator + +ENTITY_ID = "todo.mock_title_shopping_cart" + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ACCESS_TOKEN: "x-original-picnic-auth-token", + CONF_COUNTRY_CODE: "NL", + }, + unique_id="295-6y3-1nf4", + ) + + +@pytest.fixture +def mock_picnic_api(): + """Return a mocked PicnicAPI client.""" + with patch("homeassistant.components.picnic.PicnicAPI") as mock: + client = mock.return_value + client.session.auth_token = "3q29fpwhulzes" + client.get_cart.return_value = json.loads(load_fixture("picnic/cart.json")) + client.get_user.return_value = json.loads(load_fixture("picnic/user.json")) + client.get_deliveries.return_value = json.loads( + load_fixture("picnic/delivery.json") + ) + client.get_delivery_position.return_value = {} + yield client + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_picnic_api: MagicMock +) -> MockConfigEntry: + """Set up the Picnic 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() + + return mock_config_entry + + +@pytest.fixture +async def get_items( + hass_ws_client: WebSocketGenerator +) -> Callable[[], Awaitable[dict[str, str]]]: + """Fixture to fetch items from the todo websocket.""" + + async def get() -> list[dict[str, str]]: + # Fetch items using To-do platform + client = await hass_ws_client() + await client.send_json_auto_id( + { + "id": id, + "type": "todo/item/list", + "entity_id": ENTITY_ID, + } + ) + resp = await client.receive_json() + assert resp.get("success") + return resp.get("result", {}).get("items", []) + + return get diff --git a/tests/components/picnic/fixtures/cart.json b/tests/components/picnic/fixtures/cart.json new file mode 100644 index 00000000000..bde170bb26a --- /dev/null +++ b/tests/components/picnic/fixtures/cart.json @@ -0,0 +1,337 @@ +{ + "items": [ + { + "type": "ORDER_LINE", + "id": "763", + "items": [ + { + "type": "ORDER_ARTICLE", + "id": "s1001194", + "name": "Knoflook", + "image_ids": [ + "4054013cb82da80abbdcd7c8eec54f486bfa180b9cf499e94cc4013470d0dfd7" + ], + "unit_quantity": "2 stuks", + "unit_quantity_sub": "€9.08/kg", + "price": 109, + "max_count": 50, + "perishable": false, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "2 stuks" + } + ] + } + ], + "display_price": 109, + "price": 109 + }, + { + "type": "ORDER_LINE", + "id": "765_766", + "items": [ + { + "type": "ORDER_ARTICLE", + "id": "s1046297", + "name": "Picnic magere melk", + "image_ids": [ + "c2a96757634ada380726d3307e564f244cfa86e89d94c2c0e382306dbad599a3" + ], + "unit_quantity": "2 x 1 liter", + "unit_quantity_sub": "€1.02/l", + "price": 204, + "max_count": 18, + "perishable": true, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 2 + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "2 x 1 liter" + } + ] + } + ], + "display_price": 408, + "price": 408 + }, + { + "type": "ORDER_LINE", + "id": "767", + "items": [ + { + "type": "ORDER_ARTICLE", + "id": "s1010532", + "name": "Picnic magere melk", + "image_ids": [ + "aa8880361f045ffcfb9f787e9b7fc2b49907be46921bf42985506dc03baa6c2c" + ], + "unit_quantity": "1 liter", + "unit_quantity_sub": "€1.05/l", + "price": 105, + "max_count": 18, + "perishable": true, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "1 liter" + } + ] + } + ], + "display_price": 105, + "price": 105 + }, + { + "type": "ORDER_LINE", + "id": "774_775", + "items": [ + { + "type": "ORDER_ARTICLE", + "id": "s1018253", + "name": "Robijn wascapsules wit", + "image_ids": [ + "c78b809ccbcd65760f8ce897e083587ee7b3f2b9719affd80983fad722b5c2d9" + ], + "unit_quantity": "40 wasbeurten", + "price": 2899, + "max_count": 50, + "perishable": false, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "IMMUTABLE" + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "40 wasbeurten" + } + ] + }, + { + "type": "ORDER_ARTICLE", + "id": "s1007025", + "name": "Robijn wascapsules kleur", + "image_ids": [ + "ef9c8a371a639906ef20dfdcdc99296fce4102c47f0018e6329a2e4ae9f846b7" + ], + "unit_quantity": "15 wasbeurten", + "price": 879, + "max_count": 50, + "perishable": false, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "IMMUTABLE" + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "15 wasbeurten" + } + ] + } + ], + "display_price": 3778, + "price": 3778, + "decorators": [ + { + "type": "PROMO", + "text": "1+1 gratis" + }, + { + "type": "PRICE", + "display_price": 1889 + } + ] + }, + { + "type": "ORDER_LINE", + "id": "776_777_778_779_780", + "items": [ + { + "type": "ORDER_ARTICLE", + "id": "s1012699", + "name": "Chinese wokgroenten", + "image_ids": [ + "b0b547a03d1d6021565618a5d32bd35df34c57b348d73252defb776ab8f8ab76" + ], + "unit_quantity": "600 gram", + "unit_quantity_sub": "€4.92/kg", + "price": 295, + "max_count": 50, + "perishable": true, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "IMMUTABLE" + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "600 gram" + } + ] + }, + { + "type": "ORDER_ARTICLE", + "id": "s1003425", + "name": "Picnic boerderij-eitjes", + "image_ids": [ + "8be72b8144bfb7ff637d4703cfcb11e1bee789de79c069d00e879650dbf19840" + ], + "unit_quantity": "6 stuks M/L", + "price": 305, + "max_count": 50, + "perishable": true, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "IMMUTABLE" + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "6 stuks M/L" + } + ] + }, + { + "type": "ORDER_ARTICLE", + "id": "s1016692", + "name": "Picnic witte snelkookrijst", + "image_ids": [ + "9c76c0a0143bfef650ab85fff4f0918e0b4e2927d79caa2a2bf394f292a86213" + ], + "unit_quantity": "400 gram", + "unit_quantity_sub": "€3.23/kg", + "price": 129, + "max_count": 99, + "perishable": false, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "IMMUTABLE" + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "400 gram" + } + ] + }, + { + "type": "ORDER_ARTICLE", + "id": "s1012503", + "name": "Conimex kruidenmix nasi", + "image_ids": [ + "2eb78de465aa327a9739d9b204affce17fdf6bf7675c4fe9fa2d4ec102791c69" + ], + "unit_quantity": "20 gram", + "unit_quantity_sub": "€42.50/kg", + "price": 85, + "max_count": 50, + "perishable": false, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "IMMUTABLE" + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "20 gram" + } + ] + }, + { + "type": "ORDER_ARTICLE", + "id": "s1005028", + "name": "Conimex satésaus mild kant & klaar", + "image_ids": [ + "0273de24577ba25526cdf31c53ef2017c62611b2bb4d82475abb2dcd9b2f5b83" + ], + "unit_quantity": "400 gram", + "unit_quantity_sub": "€5.98/kg", + "price": 239, + "max_count": 50, + "perishable": false, + "tags": [], + "decorators": [ + { + "type": "QUANTITY", + "quantity": 1 + }, + { + "type": "IMMUTABLE" + }, + { + "type": "UNIT_QUANTITY", + "unit_quantity_text": "400 gram" + } + ] + } + ], + "display_price": 1053, + "price": 1053, + "decorators": [ + { + "type": "PROMO", + "text": "Receptkorting" + }, + { + "type": "PRICE", + "display_price": 880 + } + ] + } + ], + "delivery_slots": [ + { + "slot_id": "611a3b074872b23576bef456a", + "window_start": "2021-03-03T14:45:00.000+01:00", + "window_end": "2021-03-03T15:45:00.000+01:00", + "cut_off_time": "2021-03-02T22:00:00.000+01:00", + "minimum_order_value": 3500 + } + ], + "selected_slot": { + "slot_id": "611a3b074872b23576bef456a", + "state": "EXPLICIT" + }, + "total_count": 10, + "total_price": 2535 +} diff --git a/tests/components/picnic/fixtures/delivery.json b/tests/components/picnic/fixtures/delivery.json new file mode 100644 index 00000000000..61a7fe7ac35 --- /dev/null +++ b/tests/components/picnic/fixtures/delivery.json @@ -0,0 +1,31 @@ +{ + "delivery_id": "z28fjso23e", + "creation_time": "2021-02-24T21:48:46.395+01:00", + "slot": { + "slot_id": "602473859a40dc24c6b65879", + "hub_id": "AMS", + "window_start": "2021-02-26T20:15:00.000+01:00", + "window_end": "2021-02-26T21:15:00.000+01:00", + "cut_off_time": "2021-02-25T22:00:00.000+01:00", + "minimum_order_value": 3500 + }, + "eta2": { + "start": "2021-02-26T20:54:00.000+01:00", + "end": "2021-02-26T21:14:00.000+01:00" + }, + "status": "COMPLETED", + "delivery_time": { + "start": "2021-02-26T20:54:05.221+01:00", + "end": "2021-02-26T20:58:31.802+01:00" + }, + "orders": [ + { + "creation_time": "2021-02-24T21:48:46.418+01:00", + "total_price": 3597 + }, + { + "creation_time": "2021-02-25T17:10:26.816+01:00", + "total_price": 536 + } + ] +} diff --git a/tests/components/picnic/fixtures/user.json b/tests/components/picnic/fixtures/user.json new file mode 100644 index 00000000000..3656d11e98c --- /dev/null +++ b/tests/components/picnic/fixtures/user.json @@ -0,0 +1,14 @@ +{ + "user_id": "295-6y3-1nf4", + "firstname": "User", + "lastname": "Name", + "address": { + "house_number": 123, + "house_number_ext": "a", + "postcode": "4321 AB", + "street": "Commonstreet", + "city": "Somewhere" + }, + "total_deliveries": 123, + "completed_deliveries": 112 +} diff --git a/tests/components/picnic/snapshots/test_todo.ambr b/tests/components/picnic/snapshots/test_todo.ambr new file mode 100644 index 00000000000..4b92584c0fc --- /dev/null +++ b/tests/components/picnic/snapshots/test_todo.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_cart_list_with_items + list([ + dict({ + 'status': 'needs_action', + 'summary': 'Knoflook (2 stuks)', + 'uid': '763-s1001194', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Picnic magere melk (2 x 1 liter)', + 'uid': '765_766-s1046297', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Picnic magere melk (1 liter)', + 'uid': '767-s1010532', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Robijn wascapsules wit (40 wasbeurten)', + 'uid': '774_775-s1018253', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Robijn wascapsules kleur (15 wasbeurten)', + 'uid': '774_775-s1007025', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Chinese wokgroenten (600 gram)', + 'uid': '776_777_778_779_780-s1012699', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Picnic boerderij-eitjes (6 stuks M/L)', + 'uid': '776_777_778_779_780-s1003425', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Picnic witte snelkookrijst (400 gram)', + 'uid': '776_777_778_779_780-s1016692', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Conimex kruidenmix nasi (20 gram)', + 'uid': '776_777_778_779_780-s1012503', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Conimex satésaus mild kant & klaar (400 gram)', + 'uid': '776_777_778_779_780-s1005028', + }), + ]) +# --- diff --git a/tests/components/picnic/test_todo.py b/tests/components/picnic/test_todo.py new file mode 100644 index 00000000000..cdd30967058 --- /dev/null +++ b/tests/components/picnic/test_todo.py @@ -0,0 +1,126 @@ +"""Tests for Picnic Tasks todo platform.""" + +from unittest.mock import MagicMock, Mock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.todo import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from .conftest import ENTITY_ID + +from tests.common import MockConfigEntry + + +async def test_cart_list_with_items( + hass: HomeAssistant, + init_integration, + get_items, + snapshot: SnapshotAssertion, +) -> None: + """Test loading of shopping cart.""" + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == "10" + + assert snapshot == await get_items() + + +async def test_cart_list_empty_items( + hass: HomeAssistant, mock_picnic_api: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test loading of shopping cart without items.""" + mock_picnic_api.get_cart.return_value = {"items": []} + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == "0" + + +async def test_cart_list_unexpected_response( + hass: HomeAssistant, mock_picnic_api: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test loading of shopping cart without expected response.""" + mock_picnic_api.get_cart.return_value = {} + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is None + + +async def test_cart_list_null_response( + hass: HomeAssistant, mock_picnic_api: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test loading of shopping cart without response.""" + mock_picnic_api.get_cart.return_value = None + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is None + + +async def test_create_todo_list_item( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_picnic_api: MagicMock +) -> None: + """Test for creating a picnic cart item.""" + assert len(mock_picnic_api.get_cart.mock_calls) == 1 + + mock_picnic_api.search = Mock() + mock_picnic_api.search.return_value = [ + { + "items": [ + { + "id": 321, + "name": "Picnic Melk", + "unit_quantity": "2 liter", + } + ] + } + ] + + mock_picnic_api.add_product = Mock() + + await hass.services.async_call( + DOMAIN, + "add_item", + {"item": "Melk"}, + target={"entity_id": ENTITY_ID}, + blocking=True, + ) + + args = mock_picnic_api.search.call_args + assert args + assert args[0][0] == "Melk" + + args = mock_picnic_api.add_product.call_args + assert args + assert args[0][0] == "321" + assert args[0][1] == 1 + + assert len(mock_picnic_api.get_cart.mock_calls) == 2 + + +async def test_create_todo_list_item_not_found( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_picnic_api: MagicMock +) -> None: + """Test for creating a picnic cart item when ID is not found.""" + mock_picnic_api.search = Mock() + mock_picnic_api.search.return_value = [{"items": []}] + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + "add_item", + {"item": "Melk"}, + target={"entity_id": ENTITY_ID}, + blocking=True, + ) diff --git a/tests/components/ping/conftest.py b/tests/components/ping/conftest.py new file mode 100644 index 00000000000..4ad06a09c1c --- /dev/null +++ b/tests/components/ping/conftest.py @@ -0,0 +1,54 @@ +"""Test configuration for ping.""" +from unittest.mock import patch + +from icmplib import Host +import pytest + +from homeassistant.components.ping import DOMAIN +from homeassistant.components.ping.const import CONF_PING_COUNT +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def patch_setup(*args, **kwargs): + """Patch setup methods.""" + with patch( + "homeassistant.components.ping.async_setup_entry", + return_value=True, + ), patch("homeassistant.components.ping.async_setup", return_value=True): + yield + + +@pytest.fixture(autouse=True) +async def patch_ping(): + """Patch icmplib async_ping.""" + mock = Host("10.10.10.10", 5, [10, 1, 2]) + + with patch( + "homeassistant.components.ping.helpers.async_ping", return_value=mock + ), patch("homeassistant.components.ping.async_ping", return_value=mock): + yield mock + + +@pytest.fixture(name="config_entry") +async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a MockConfigEntry for testing.""" + return MockConfigEntry( + domain=DOMAIN, + title="10.10.10.10", + options={CONF_HOST: "10.10.10.10", CONF_PING_COUNT: 10.0}, + ) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry, patch_ping +) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/ping/const.py b/tests/components/ping/const.py new file mode 100644 index 00000000000..cf002dc7ca6 --- /dev/null +++ b/tests/components/ping/const.py @@ -0,0 +1,11 @@ +"""Constants for tests.""" +from icmplib import Host + +BINARY_SENSOR_IMPORT_DATA = { + "name": "test2", + "host": "127.0.0.1", + "count": 1, + "scan_interval": 50, +} + +NON_AVAILABLE_HOST_PING = Host("192.168.178.1", 10, []) diff --git a/tests/components/ping/fixtures/configuration.yaml b/tests/components/ping/fixtures/configuration.yaml deleted file mode 100644 index 201c020835e..00000000000 --- a/tests/components/ping/fixtures/configuration.yaml +++ /dev/null @@ -1,5 +0,0 @@ -binary_sensor: - - platform: ping - name: test2 - host: 127.0.0.1 - count: 1 diff --git a/tests/components/ping/snapshots/test_binary_sensor.ambr b/tests/components/ping/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..2ce320d561b --- /dev/null +++ b/tests/components/ping/snapshots/test_binary_sensor.ambr @@ -0,0 +1,121 @@ +# serializer version: 1 +# name: test_sensor + 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.10_10_10_10', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '10.10.10.10', + 'platform': 'ping', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': '10.10.10.10', + 'round_trip_time_avg': 4.333, + 'round_trip_time_max': 10, + 'round_trip_time_mdev': '', + 'round_trip_time_min': 1, + }), + 'context': , + 'entity_id': 'binary_sensor.10_10_10_10', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': '10.10.10.10', + }), + 'context': , + 'entity_id': 'binary_sensor.10_10_10_10', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_and_update + 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.10_10_10_10', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '10.10.10.10', + 'platform': 'ping', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_and_update.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': '10.10.10.10', + 'round_trip_time_avg': 4.333, + 'round_trip_time_max': 10, + 'round_trip_time_mdev': '', + 'round_trip_time_min': 1, + }), + 'context': , + 'entity_id': 'binary_sensor.10_10_10_10', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_and_update.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': '10.10.10.10', + }), + 'context': , + 'entity_id': 'binary_sensor.10_10_10_10', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/ping/test_binary_sensor.py b/tests/components/ping/test_binary_sensor.py index 3389534483f..b1066895e2b 100644 --- a/tests/components/ping/test_binary_sensor.py +++ b/tests/components/ping/test_binary_sensor.py @@ -1,27 +1,75 @@ -"""The test for the ping binary_sensor platform.""" +"""Test the binary sensor platform of ping.""" +from datetime import timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory +from icmplib import Host import pytest +from syrupy import SnapshotAssertion +from syrupy.filters import props -from homeassistant import config as hass_config, setup -from homeassistant.components.ping import DOMAIN -from homeassistant.const import SERVICE_RELOAD -from homeassistant.core import HomeAssistant +from homeassistant.components.ping.const import CONF_IMPORTED_BY, DOMAIN +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component -from tests.common import get_fixture_path +from tests.common import MockConfigEntry -@pytest.fixture -def mock_ping() -> None: - """Mock icmplib.ping.""" - with patch("homeassistant.components.ping.icmp_ping"): - yield +@pytest.mark.usefixtures("setup_integration") +async def test_setup_and_update( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Test sensor setup and update.""" + + # check if binary sensor is there + entry = entity_registry.async_get("binary_sensor.10_10_10_10") + assert entry == snapshot(exclude=props("unique_id")) + + state = hass.states.get("binary_sensor.10_10_10_10") + assert state == snapshot + + # check if the sensor turns off. + with patch( + "homeassistant.components.ping.helpers.async_ping", + return_value=Host(address="10.10.10.10", packets_sent=10, rtts=[]), + ): + freezer.tick(timedelta(minutes=6)) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.10_10_10_10") + assert state == snapshot -async def test_reload(hass: HomeAssistant, mock_ping: None) -> None: - """Verify we can reload trend sensors.""" +async def test_disabled_after_import( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +): + """Test if binary sensor is disabled after import.""" + config_entry.data = {CONF_IMPORTED_BY: "device_tracker"} + config_entry.add_to_hass(hass) - await setup.async_setup_component( + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # check if entity is disabled after import by device tracker + entry = entity_registry.async_get("binary_sensor.10_10_10_10") + assert entry + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +async def test_import_issue_creation( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +): + """Test if import issue is raised.""" + + await async_setup_component( hass, "binary_sensor", { @@ -35,21 +83,7 @@ async def test_reload(hass: HomeAssistant, mock_ping: None) -> None: ) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 - - assert hass.states.get("binary_sensor.test") - - yaml_path = get_fixture_path("configuration.yaml", "ping") - with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): - await hass.services.async_call( - DOMAIN, - SERVICE_RELOAD, - {}, - blocking=True, - ) - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == 1 - - assert hass.states.get("binary_sensor.test") is None - assert hass.states.get("binary_sensor.test2") + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) + assert issue diff --git a/tests/components/ping/test_config_flow.py b/tests/components/ping/test_config_flow.py new file mode 100644 index 00000000000..6fff4ae7c71 --- /dev/null +++ b/tests/components/ping/test_config_flow.py @@ -0,0 +1,122 @@ +"""Test the Ping (ICMP) config flow.""" +from __future__ import annotations + +import pytest + +from homeassistant import config_entries +from homeassistant.components.ping import DOMAIN +from homeassistant.components.ping.const import CONF_IMPORTED_BY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import BINARY_SENSOR_IMPORT_DATA + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("host", "expected_title"), + (("192.618.178.1", "192.618.178.1"),), +) +@pytest.mark.usefixtures("patch_setup") +async def test_form(hass: HomeAssistant, host, expected_title) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": host, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == expected_title + assert result["data"] == {} + assert result["options"] == { + "count": 5, + "host": host, + } + + +@pytest.mark.parametrize( + ("host", "count", "expected_title"), + (("192.618.178.1", 10, "192.618.178.1"),), +) +@pytest.mark.usefixtures("patch_setup") +async def test_options(hass: HomeAssistant, host, count, expected_title) -> None: + """Test options flow.""" + + config_entry = MockConfigEntry( + version=1, + source=config_entries.SOURCE_USER, + data={}, + domain=DOMAIN, + options={"count": count, "host": host}, + title=expected_title, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + { + "host": "10.10.10.1", + "count": count, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "count": count, + "host": "10.10.10.1", + } + + +@pytest.mark.usefixtures("patch_setup") +async def test_step_import(hass: HomeAssistant) -> None: + """Test for import step.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_IMPORTED_BY: "binary_sensor", **BINARY_SENSOR_IMPORT_DATA}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test2" + assert result["data"] == {CONF_IMPORTED_BY: "binary_sensor"} + assert result["options"] == { + "host": "127.0.0.1", + "count": 1, + } + + # test import without name + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_IMPORTED_BY: "binary_sensor", "host": "10.10.10.10", "count": 5}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "10.10.10.10" + assert result["data"] == {CONF_IMPORTED_BY: "binary_sensor"} + assert result["options"] == { + "host": "10.10.10.10", + "count": 5, + } diff --git a/tests/components/ping/test_device_tracker.py b/tests/components/ping/test_device_tracker.py new file mode 100644 index 00000000000..5f5bb2132c1 --- /dev/null +++ b/tests/components/ping/test_device_tracker.py @@ -0,0 +1,101 @@ +"""Test the binary sensor platform of ping.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.device_tracker import legacy +from homeassistant.components.ping.const import DOMAIN +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component +from homeassistant.util.yaml import dump + +from tests.common import MockConfigEntry, patch_yaml_files + + +@pytest.mark.usefixtures("setup_integration") +async def test_setup_and_update( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, +) -> None: + """Test sensor setup and update.""" + + entry = entity_registry.async_get("device_tracker.10_10_10_10") + assert entry + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + # check device tracker state is not there + state = hass.states.get("device_tracker.10_10_10_10") + assert state is None + + # enable the entity + updated_entry = entity_registry.async_update_entity( + entity_id="device_tracker.10_10_10_10", disabled_by=None + ) + assert updated_entry != entry + assert updated_entry.disabled is False + + # reload config entry to enable entity + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # check device tracker is now "home" + state = hass.states.get("device_tracker.10_10_10_10") + assert state.state == "home" + + +async def test_import_issue_creation( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +): + """Test if import issue is raised.""" + + await async_setup_component( + hass, + "device_tracker", + {"device_tracker": {"platform": "ping", "hosts": {"test": "10.10.10.10"}}}, + ) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) + assert issue + + +async def test_import_delete_known_devices( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +): + """Test if import deletes known devices.""" + yaml_devices = { + "test": { + "hide_if_away": True, + "mac": "00:11:22:33:44:55", + "name": "Test name", + "picture": "/local/test.png", + "track": True, + }, + } + files = {legacy.YAML_DEVICES: dump(yaml_devices)} + + with patch_yaml_files(files, True), patch( + "homeassistant.components.ping.device_tracker.remove_device_from_config" + ) as remove_device_from_config: + await async_setup_component( + hass, + "device_tracker", + {"device_tracker": {"platform": "ping", "hosts": {"test": "10.10.10.10"}}}, + ) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert len(remove_device_from_config.mock_calls) == 1 diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 235596715f4..47d70727890 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -851,7 +851,7 @@ async def test_client_header_issues( ), patch( "homeassistant.components.http.current_request.get", return_value=MockRequest() ), pytest.raises( - RuntimeError + RuntimeError, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} diff --git a/tests/components/plugwise/fixtures/adam_jip/all_data.json b/tests/components/plugwise/fixtures/adam_jip/all_data.json index bc1bc9c8c0c..dacee20c644 100644 --- a/tests/components/plugwise/fixtures/adam_jip/all_data.json +++ b/tests/components/plugwise/fixtures/adam_jip/all_data.json @@ -8,7 +8,7 @@ "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", "location": "06aecb3d00354375924f50c47af36bd2", - "mode": "heat", + "mode": "off", "model": "Lisa", "name": "Slaapkamer", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], 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 e7e13e17357..9ef93d63bdd 100644 --- a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json @@ -21,6 +21,7 @@ "binary_sensors": { "compressor_state": true, "cooling_enabled": false, + "cooling_state": false, "dhw_state": false, "flame_state": false, "heating_state": true, @@ -40,7 +41,7 @@ "setpoint": 60.0, "upper_bound": 100.0 }, - "model": "Generic heater", + "model": "Generic heater/cooler", "name": "OpenTherm", "sensors": { "dhw_temperature": 46.3, @@ -72,7 +73,8 @@ "cooling_activation_outdoor_temperature": 21.0, "cooling_deactivation_threshold": 4.0, "illuminance": 86.0, - "setpoint": 20.5, + "setpoint_high": 30.0, + "setpoint_low": 20.5, "temperature": 19.3 }, "temperature_offset": { @@ -84,16 +86,18 @@ "thermostat": { "lower_bound": 4.0, "resolution": 0.1, - "setpoint": 20.5, + "setpoint_high": 30.0, + "setpoint_low": 20.5, "upper_bound": 30.0 }, "vendor": "Plugwise" } }, "gateway": { - "cooling_present": false, + "cooling_present": true, "gateway_id": "015ae9ea3f964e668e490fa39da3870b", "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", + "item_count": 66, "notifications": {}, "smile_name": "Smile Anna" } 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 126852e945d..624547155a3 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -55,22 +55,20 @@ "available_schedules": ["Weekschema", "Badkamer", "Test"], "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", - "mode": "heat_cool", + "mode": "cool", "model": "ThermoTouch", "name": "Anna", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "select_schedule": "Weekschema", "selected_schedule": "None", "sensors": { - "setpoint_high": 23.5, - "setpoint_low": 4.0, + "setpoint": 23.5, "temperature": 25.8 }, "thermostat": { "lower_bound": 1.0, "resolution": 0.01, - "setpoint_high": 23.5, - "setpoint_low": 4.0, + "setpoint": 23.5, "upper_bound": 35.0 }, "vendor": "Plugwise" @@ -115,9 +113,8 @@ "select_schedule": "Badkamer", "sensors": { "battery": 56, - "setpoint_high": 23.5, - "setpoint_low": 20.0, - "temperature": 239 + "setpoint": 23.5, + "temperature": 23.9 }, "temperature_offset": { "lower_bound": -2.0, @@ -128,8 +125,7 @@ "thermostat": { "lower_bound": 0.0, "resolution": 0.01, - "setpoint_high": 25.0, - "setpoint_low": 19.0, + "setpoint": 25.0, "upper_bound": 99.9 }, "vendor": "Plugwise", 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 40364e620c3..844eae4c2f7 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 @@ -73,7 +73,7 @@ "cooling_activation_outdoor_temperature": 21.0, "cooling_deactivation_threshold": 4.0, "illuminance": 86.0, - "setpoint_high": 24.0, + "setpoint_high": 30.0, "setpoint_low": 20.5, "temperature": 26.3 }, @@ -86,7 +86,7 @@ "thermostat": { "lower_bound": 4.0, "resolution": 0.1, - "setpoint_high": 24.0, + "setpoint_high": 30.0, "setpoint_low": 20.5, "upper_bound": 30.0 }, @@ -97,6 +97,7 @@ "cooling_present": true, "gateway_id": "015ae9ea3f964e668e490fa39da3870b", "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", + "item_count": 66, "notifications": {}, "smile_name": "Smile Anna" } 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 3a84a59deea..f6be6f35188 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 @@ -73,7 +73,7 @@ "cooling_activation_outdoor_temperature": 25.0, "cooling_deactivation_threshold": 4.0, "illuminance": 86.0, - "setpoint_high": 24.0, + "setpoint_high": 30.0, "setpoint_low": 20.5, "temperature": 23.0 }, @@ -86,7 +86,7 @@ "thermostat": { "lower_bound": 4.0, "resolution": 0.1, - "setpoint_high": 24.0, + "setpoint_high": 30.0, "setpoint_low": 20.5, "upper_bound": 30.0 }, @@ -97,6 +97,7 @@ "cooling_present": true, "gateway_id": "015ae9ea3f964e668e490fa39da3870b", "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", + "item_count": 66, "notifications": {}, "smile_name": "Smile Anna" } diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index d8ce2785f2a..c14fd802e3b 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -13,6 +13,10 @@ from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry, async_fire_time_changed +HA_PLUGWISE_SMILE_ASYNC_UPDATE = ( + "homeassistant.components.plugwise.coordinator.Smile.async_update" +) + async def test_adam_climate_entity_attributes( hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry @@ -21,7 +25,7 @@ async def test_adam_climate_entity_attributes( state = hass.states.get("climate.zone_lisa_wk") assert state assert state.state == HVACMode.AUTO - assert state.attributes["hvac_modes"] == [HVACMode.HEAT, 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 @@ -33,13 +37,13 @@ async def test_adam_climate_entity_attributes( assert state.attributes["supported_features"] == 17 assert state.attributes["temperature"] == 21.5 assert state.attributes["min_temp"] == 0.0 - assert state.attributes["max_temp"] == 99.9 + assert state.attributes["max_temp"] == 35.0 assert state.attributes["target_temp_step"] == 0.1 state = hass.states.get("climate.zone_thermostat_jessie") assert state assert state.state == HVACMode.AUTO - assert state.attributes["hvac_modes"] == [HVACMode.HEAT, 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 @@ -50,7 +54,7 @@ async def test_adam_climate_entity_attributes( assert state.attributes["preset_mode"] == "asleep" assert state.attributes["temperature"] == 15.0 assert state.attributes["min_temp"] == 0.0 - assert state.attributes["max_temp"] == 99.9 + assert state.attributes["max_temp"] == 35.0 assert state.attributes["target_temp_step"] == 0.1 @@ -62,13 +66,21 @@ async def test_adam_2_climate_entity_attributes( assert state assert state.state == HVACMode.HEAT assert state.attributes["hvac_action"] == "heating" - assert state.attributes["hvac_modes"] == [HVACMode.HEAT, HVACMode.AUTO] + assert state.attributes["hvac_modes"] == [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.HEAT, + ] state = hass.states.get("climate.lisa_badkamer") assert state assert state.state == HVACMode.AUTO assert state.attributes["hvac_action"] == "heating" - assert state.attributes["hvac_modes"] == [HVACMode.HEAT, HVACMode.AUTO] + assert state.attributes["hvac_modes"] == [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.HEAT, + ] async def test_adam_3_climate_entity_attributes( @@ -78,11 +90,58 @@ async def test_adam_3_climate_entity_attributes( state = hass.states.get("climate.anna") assert state - assert state.state == HVACMode.HEAT_COOL + assert state.state == HVACMode.COOL assert state.attributes["hvac_action"] == "cooling" assert state.attributes["hvac_modes"] == [ - HVACMode.HEAT_COOL, + HVACMode.OFF, HVACMode.AUTO, + HVACMode.COOL, + ] + data = mock_smile_adam_3.async_update.return_value + data.devices["da224107914542988a88561b4452b0f6"][ + "select_regulation_mode" + ] = "heating" + data.devices["ad4838d7d35c4d6ea796ee12ae5aedf8"]["mode"] = "heat" + data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ + "cooling_state" + ] = False + data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ + "heating_state" + ] = True + with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): + async_fire_time_changed(hass, utcnow() + timedelta(minutes=1)) + await hass.async_block_till_done() + state = hass.states.get("climate.anna") + assert state + assert state.state == HVACMode.HEAT + assert state.attributes["hvac_action"] == "heating" + assert state.attributes["hvac_modes"] == [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.HEAT, + ] + data = mock_smile_adam_3.async_update.return_value + data.devices["da224107914542988a88561b4452b0f6"][ + "select_regulation_mode" + ] = "cooling" + data.devices["ad4838d7d35c4d6ea796ee12ae5aedf8"]["mode"] = "cool" + data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ + "cooling_state" + ] = True + data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ + "heating_state" + ] = False + with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): + async_fire_time_changed(hass, utcnow() + timedelta(minutes=1)) + await hass.async_block_till_done() + state = hass.states.get("climate.anna") + assert state + assert state.state == HVACMode.COOL + assert state.attributes["hvac_action"] == "cooling" + assert state.attributes["hvac_modes"] == [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.COOL, ] @@ -173,6 +232,60 @@ async def test_adam_climate_entity_climate_changes( ) +async def test_adam_climate_off_mode_change( + hass: HomeAssistant, + mock_smile_adam_4: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test handling of user requests in adam climate device environment.""" + state = hass.states.get("climate.slaapkamer") + assert state + assert state.state == HVACMode.OFF + await hass.services.async_call( + "climate", + "set_hvac_mode", + { + "entity_id": "climate.slaapkamer", + "hvac_mode": "heat", + }, + blocking=True, + ) + assert mock_smile_adam_4.set_schedule_state.call_count == 1 + assert mock_smile_adam_4.set_regulation_mode.call_count == 1 + mock_smile_adam_4.set_regulation_mode.assert_called_with("heating") + + state = hass.states.get("climate.kinderkamer") + assert state + assert state.state == HVACMode.HEAT + await hass.services.async_call( + "climate", + "set_hvac_mode", + { + "entity_id": "climate.kinderkamer", + "hvac_mode": "off", + }, + blocking=True, + ) + assert mock_smile_adam_4.set_schedule_state.call_count == 1 + assert mock_smile_adam_4.set_regulation_mode.call_count == 2 + mock_smile_adam_4.set_regulation_mode.assert_called_with("off") + + state = hass.states.get("climate.logeerkamer") + assert state + assert state.state == HVACMode.HEAT + await hass.services.async_call( + "climate", + "set_hvac_mode", + { + "entity_id": "climate.logeerkamer", + "hvac_mode": "heat", + }, + blocking=True, + ) + assert mock_smile_adam_4.set_schedule_state.call_count == 1 + assert mock_smile_adam_4.set_regulation_mode.call_count == 2 + + async def test_anna_climate_entity_attributes( hass: HomeAssistant, mock_smile_anna: MagicMock, @@ -183,20 +296,18 @@ async def test_anna_climate_entity_attributes( assert state assert state.state == HVACMode.AUTO assert state.attributes["hvac_action"] == "heating" - assert state.attributes["hvac_modes"] == [ - HVACMode.HEAT, - HVACMode.AUTO, - ] + assert state.attributes["hvac_modes"] == [HVACMode.AUTO, HVACMode.HEAT_COOL] assert "no_frost" in state.attributes["preset_modes"] assert "home" in state.attributes["preset_modes"] assert state.attributes["current_temperature"] == 19.3 assert state.attributes["preset_mode"] == "home" - assert state.attributes["supported_features"] == 17 - assert state.attributes["temperature"] == 20.5 - assert state.attributes["min_temp"] == 4.0 - assert state.attributes["max_temp"] == 30.0 + 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 @@ -211,11 +322,11 @@ async def test_anna_2_climate_entity_attributes( assert state.state == HVACMode.AUTO assert state.attributes["hvac_action"] == "cooling" assert state.attributes["hvac_modes"] == [ - HVACMode.HEAT_COOL, HVACMode.AUTO, + HVACMode.HEAT_COOL, ] assert state.attributes["supported_features"] == 18 - assert state.attributes["target_temp_high"] == 24.0 + assert state.attributes["target_temp_high"] == 30 assert state.attributes["target_temp_low"] == 20.5 @@ -230,8 +341,8 @@ async def test_anna_3_climate_entity_attributes( assert state.state == HVACMode.AUTO assert state.attributes["hvac_action"] == "idle" assert state.attributes["hvac_modes"] == [ - HVACMode.HEAT_COOL, HVACMode.AUTO, + HVACMode.HEAT_COOL, ] @@ -244,13 +355,13 @@ async def test_anna_climate_entity_climate_changes( await hass.services.async_call( "climate", "set_temperature", - {"entity_id": "climate.anna", "target_temp_high": 25, "target_temp_low": 20}, + {"entity_id": "climate.anna", "target_temp_high": 30, "target_temp_low": 20}, blocking=True, ) assert mock_smile_anna.set_temperature.call_count == 1 mock_smile_anna.set_temperature.assert_called_with( "c784ee9fdab44e1395b8dee7d7a497d5", - {"setpoint_high": 25.0, "setpoint_low": 20.0}, + {"setpoint_high": 30.0, "setpoint_low": 20.0}, ) await hass.services.async_call( @@ -270,29 +381,24 @@ async def test_anna_climate_entity_climate_changes( {"entity_id": "climate.anna", "hvac_mode": "auto"}, blocking=True, ) - assert mock_smile_anna.set_schedule_state.call_count == 1 - mock_smile_anna.set_schedule_state.assert_called_with( - "c784ee9fdab44e1395b8dee7d7a497d5", "on" - ) + # hvac_mode is already auto so not called. + assert mock_smile_anna.set_schedule_state.call_count == 0 await hass.services.async_call( "climate", "set_hvac_mode", - {"entity_id": "climate.anna", "hvac_mode": "heat"}, + {"entity_id": "climate.anna", "hvac_mode": "heat_cool"}, blocking=True, ) - assert mock_smile_anna.set_schedule_state.call_count == 2 + assert mock_smile_anna.set_schedule_state.call_count == 1 mock_smile_anna.set_schedule_state.assert_called_with( "c784ee9fdab44e1395b8dee7d7a497d5", "off" ) data = mock_smile_anna.async_update.return_value data.devices["3cb70739631c4d17a86b8b12e8a5161b"]["available_schedules"] = ["None"] - with patch( - "homeassistant.components.plugwise.coordinator.Smile.async_update", - return_value=data, - ): + with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): async_fire_time_changed(hass, utcnow() + timedelta(minutes=1)) await hass.async_block_till_done() state = hass.states.get("climate.anna") assert state.state == HVACMode.HEAT - assert state.attributes["hvac_modes"] == [HVACMode.HEAT] + assert state.attributes["hvac_modes"] == [HVACMode.HEAT_COOL] diff --git a/tests/components/plugwise/test_select.py b/tests/components/plugwise/test_select.py index 9df20a5ffc8..f1220a07a2b 100644 --- a/tests/components/plugwise/test_select.py +++ b/tests/components/plugwise/test_select.py @@ -16,7 +16,7 @@ from tests.common import MockConfigEntry async def test_adam_select_entities( hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry ) -> None: - """Test a select.""" + """Test a thermostat Select.""" state = hass.states.get("select.zone_lisa_wk_thermostat_schedule") assert state @@ -44,3 +44,27 @@ async def test_adam_change_select_entity( "on", "Badkamer Schema", ) + + +async def test_adam_select_regulation_mode( + hass: HomeAssistant, mock_smile_adam_3: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test a regulation_mode select. + + Also tests a change in climate _previous mode. + """ + + state = hass.states.get("select.adam_regulation_mode") + assert state + assert state.state == "cooling" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + "entity_id": "select.adam_regulation_mode", + "option": "heating", + }, + blocking=True, + ) + assert mock_smile_adam_3.set_regulation_mode.call_count == 1 + mock_smile_adam_3.set_regulation_mode.assert_called_with("heating") diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index f28c7b5081b..af2f2ba5784 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -20,6 +20,7 @@ from homeassistant.components import ( input_number, light, lock, + number, person, prometheus, sensor, @@ -292,6 +293,30 @@ async def test_input_number(client, input_number_entities) -> None: ) +@pytest.mark.parametrize("namespace", [""]) +async def test_number(client, number_entities) -> None: + """Test prometheus metrics for number.""" + body = await generate_latest_metrics(client) + + assert ( + 'number_state{domain="number",' + 'entity="number.threshold",' + 'friendly_name="Threshold"} 5.2' in body + ) + + assert ( + 'number_state{domain="number",' + 'entity="number.brightness",' + 'friendly_name="None"} 60.0' in body + ) + + assert ( + 'number_state_celsius{domain="number",' + 'entity="number.target_temperature",' + 'friendly_name="Target temperature"} 22.7' in body + ) + + @pytest.mark.parametrize("namespace", [""]) async def test_battery(client, sensor_entities) -> None: """Test prometheus metrics for battery.""" @@ -1388,6 +1413,46 @@ async def input_number_fixture( return data +@pytest.fixture(name="number_entities") +async def number_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: + """Simulate number entities.""" + data = {} + number_1 = entity_registry.async_get_or_create( + domain=number.DOMAIN, + platform="test", + unique_id="number_1", + suggested_object_id="threshold", + original_name="Threshold", + ) + set_state_with_entry(hass, number_1, 5.2) + data["number_1"] = number_1 + + number_2 = entity_registry.async_get_or_create( + domain=number.DOMAIN, + platform="test", + unique_id="number_2", + suggested_object_id="brightness", + ) + set_state_with_entry(hass, number_2, 60) + data["number_2"] = number_2 + + number_3 = entity_registry.async_get_or_create( + domain=number.DOMAIN, + platform="test", + unique_id="number_3", + suggested_object_id="target_temperature", + original_name="Target temperature", + unit_of_measurement=UnitOfTemperature.CELSIUS, + ) + set_state_with_entry(hass, number_3, 22.7) + data["number_3"] = number_3 + + await hass.async_block_till_done() + return data + + @pytest.fixture(name="input_boolean_entities") async def input_boolean_fixture( hass: HomeAssistant, entity_registry: er.EntityRegistry diff --git a/tests/components/pvoutput/conftest.py b/tests/components/pvoutput/conftest.py index 844bf157342..2bf85e5070e 100644 --- a/tests/components/pvoutput/conftest.py +++ b/tests/components/pvoutput/conftest.py @@ -11,7 +11,7 @@ from homeassistant.components.pvoutput.const import CONF_SYSTEM_ID, DOMAIN from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture @@ -34,41 +34,21 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: yield mock_setup -@pytest.fixture -def mock_pvoutput_config_flow() -> Generator[None, MagicMock, None]: - """Return a mocked PVOutput client.""" - with patch( - "homeassistant.components.pvoutput.config_flow.PVOutput", autospec=True - ) as pvoutput_mock: - yield pvoutput_mock.return_value - - @pytest.fixture def mock_pvoutput() -> Generator[None, MagicMock, None]: """Return a mocked PVOutput client.""" - status = Status( - reported_date="20211229", - reported_time="22:37", - energy_consumption=1000, - energy_generation=500, - normalized_output=0.5, - power_consumption=2500, - power_generation=1500, - temperature=20.2, - voltage=220.5, - ) - - system = System( - inverter_brand="Super Inverters Inc.", - system_name="Frenck's Solar Farm", - ) - with patch( "homeassistant.components.pvoutput.coordinator.PVOutput", autospec=True - ) as pvoutput_mock: + ) as pvoutput_mock, patch( + "homeassistant.components.pvoutput.config_flow.PVOutput", new=pvoutput_mock + ): pvoutput = pvoutput_mock.return_value - pvoutput.status.return_value = status - pvoutput.system.return_value = system + pvoutput.status.return_value = Status.from_dict( + load_json_object_fixture("status.json", DOMAIN) + ) + pvoutput.system.return_value = System.from_dict( + load_json_object_fixture("system.json", DOMAIN) + ) yield pvoutput diff --git a/tests/components/pvoutput/fixtures/status.json b/tests/components/pvoutput/fixtures/status.json new file mode 100644 index 00000000000..82dfb31c544 --- /dev/null +++ b/tests/components/pvoutput/fixtures/status.json @@ -0,0 +1,11 @@ +{ + "energy_consumption": 1000, + "energy_generation": 500, + "normalized_output": 0.5, + "power_consumption": 2500, + "power_generation": 1500, + "reported_date": "20210101", + "reported_time": "22:37", + "temperature": 20.2, + "voltage": 220.5 +} diff --git a/tests/components/pvoutput/fixtures/system.json b/tests/components/pvoutput/fixtures/system.json new file mode 100644 index 00000000000..c7b14c80609 --- /dev/null +++ b/tests/components/pvoutput/fixtures/system.json @@ -0,0 +1,18 @@ +{ + "array_tilt": 30, + "install_date": "20210101", + "inverter_brand": "Super Inverters Inc.", + "inverter_power": 5000, + "inverters": 1, + "latitude": 52.0, + "longitude": 4.0, + "orientation": "N", + "panel_brand": "Super Panels Inc.", + "panel_power": 250, + "panels": 20, + "shade": 0.1, + "status_interval": 5, + "system_name": "Frenck's Solar Farm", + "system_size": 5, + "zipcode": 1234 +} diff --git a/tests/components/pvoutput/snapshots/test_diagnostics.ambr b/tests/components/pvoutput/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..6ca0ce43b8d --- /dev/null +++ b/tests/components/pvoutput/snapshots/test_diagnostics.ambr @@ -0,0 +1,14 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'energy_consumption': 1000.0, + 'energy_generation': 500.0, + 'normalized_output': 0.5, + 'power_consumption': 2500.0, + 'power_generation': 1500.0, + 'reported_date': '20210101', + 'reported_time': '22:37:00', + 'temperature': 20.2, + 'voltage': 220.5, + }) +# --- diff --git a/tests/components/pvoutput/test_config_flow.py b/tests/components/pvoutput/test_config_flow.py index bf05afa020d..1839a7f51e0 100644 --- a/tests/components/pvoutput/test_config_flow.py +++ b/tests/components/pvoutput/test_config_flow.py @@ -15,7 +15,7 @@ from tests.common import MockConfigEntry async def test_full_user_flow( hass: HomeAssistant, - mock_pvoutput_config_flow: MagicMock, + mock_pvoutput: MagicMock, mock_setup_entry: AsyncMock, ) -> None: """Test the full user configuration flow.""" @@ -42,12 +42,12 @@ async def test_full_user_flow( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_pvoutput_config_flow.system.mock_calls) == 1 + assert len(mock_pvoutput.system.mock_calls) == 1 async def test_full_flow_with_authentication_error( hass: HomeAssistant, - mock_pvoutput_config_flow: MagicMock, + mock_pvoutput: MagicMock, mock_setup_entry: AsyncMock, ) -> None: """Test the full user configuration flow with incorrect API key. @@ -62,7 +62,7 @@ async def test_full_flow_with_authentication_error( assert result.get("type") == FlowResultType.FORM assert result.get("step_id") == "user" - mock_pvoutput_config_flow.system.side_effect = PVOutputAuthenticationError + mock_pvoutput.system.side_effect = PVOutputAuthenticationError result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -76,9 +76,9 @@ async def test_full_flow_with_authentication_error( assert result2.get("errors") == {"base": "invalid_auth"} assert len(mock_setup_entry.mock_calls) == 0 - assert len(mock_pvoutput_config_flow.system.mock_calls) == 1 + assert len(mock_pvoutput.system.mock_calls) == 1 - mock_pvoutput_config_flow.system.side_effect = None + mock_pvoutput.system.side_effect = None result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], user_input={ @@ -95,14 +95,12 @@ async def test_full_flow_with_authentication_error( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_pvoutput_config_flow.system.mock_calls) == 2 + assert len(mock_pvoutput.system.mock_calls) == 2 -async def test_connection_error( - hass: HomeAssistant, mock_pvoutput_config_flow: MagicMock -) -> None: +async def test_connection_error(hass: HomeAssistant, mock_pvoutput: MagicMock) -> None: """Test API connection error.""" - mock_pvoutput_config_flow.system.side_effect = PVOutputConnectionError + mock_pvoutput.system.side_effect = PVOutputConnectionError result = await hass.config_entries.flow.async_init( DOMAIN, @@ -116,13 +114,13 @@ async def test_connection_error( assert result.get("type") == FlowResultType.FORM assert result.get("errors") == {"base": "cannot_connect"} - assert len(mock_pvoutput_config_flow.system.mock_calls) == 1 + assert len(mock_pvoutput.system.mock_calls) == 1 async def test_already_configured( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_pvoutput_config_flow: MagicMock, + mock_pvoutput: MagicMock, ) -> None: """Test we abort if the PVOutput system is already configured.""" mock_config_entry.add_to_hass(hass) @@ -146,7 +144,7 @@ async def test_already_configured( async def test_reauth_flow( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_pvoutput_config_flow: MagicMock, + mock_pvoutput: MagicMock, mock_setup_entry: AsyncMock, ) -> None: """Test the reauthentication configuration flow.""" @@ -178,13 +176,13 @@ async def test_reauth_flow( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_pvoutput_config_flow.system.mock_calls) == 1 + assert len(mock_pvoutput.system.mock_calls) == 1 async def test_reauth_with_authentication_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_pvoutput_config_flow: MagicMock, + mock_pvoutput: MagicMock, mock_setup_entry: AsyncMock, ) -> None: """Test the reauthentication configuration flow with an authentication error. @@ -206,7 +204,7 @@ async def test_reauth_with_authentication_error( assert result.get("type") == FlowResultType.FORM assert result.get("step_id") == "reauth_confirm" - mock_pvoutput_config_flow.system.side_effect = PVOutputAuthenticationError + mock_pvoutput.system.side_effect = PVOutputAuthenticationError result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_API_KEY: "invalid_key"}, @@ -218,9 +216,9 @@ async def test_reauth_with_authentication_error( assert result2.get("errors") == {"base": "invalid_auth"} assert len(mock_setup_entry.mock_calls) == 0 - assert len(mock_pvoutput_config_flow.system.mock_calls) == 1 + assert len(mock_pvoutput.system.mock_calls) == 1 - mock_pvoutput_config_flow.system.side_effect = None + mock_pvoutput.system.side_effect = None result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], user_input={CONF_API_KEY: "valid_key"}, @@ -235,12 +233,12 @@ async def test_reauth_with_authentication_error( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_pvoutput_config_flow.system.mock_calls) == 2 + assert len(mock_pvoutput.system.mock_calls) == 2 async def test_reauth_api_error( hass: HomeAssistant, - mock_pvoutput_config_flow: MagicMock, + mock_pvoutput: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test API error during reauthentication.""" @@ -258,7 +256,7 @@ async def test_reauth_api_error( assert result.get("type") == FlowResultType.FORM assert result.get("step_id") == "reauth_confirm" - mock_pvoutput_config_flow.system.side_effect = PVOutputConnectionError + mock_pvoutput.system.side_effect = PVOutputConnectionError result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_API_KEY: "some_new_key"}, diff --git a/tests/components/pvoutput/test_diagnostics.py b/tests/components/pvoutput/test_diagnostics.py index 1a0c0f1148b..1ac342bc850 100644 --- a/tests/components/pvoutput/test_diagnostics.py +++ b/tests/components/pvoutput/test_diagnostics.py @@ -1,5 +1,7 @@ """Tests for the diagnostics data provided by the PVOutput integration.""" +from syrupy.assertion import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -11,18 +13,10 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "energy_consumption": 1000, - "energy_generation": 500, - "normalized_output": 0.5, - "power_consumption": 2500, - "power_generation": 1500, - "reported_date": "2021-12-29", - "reported_time": "22:37:00", - "temperature": 20.2, - "voltage": 220.5, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/pvoutput/test_sensor.py b/tests/components/pvoutput/test_sensor.py index afba339195a..61f55e1f552 100644 --- a/tests/components/pvoutput/test_sensor.py +++ b/tests/components/pvoutput/test_sensor.py @@ -35,7 +35,7 @@ async def test_sensors( assert state assert entry.unique_id == "12345_energy_consumption" assert entry.entity_category is None - assert state.state == "1000" + assert state.state == "1000.0" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert ( state.attributes.get(ATTR_FRIENDLY_NAME) @@ -51,7 +51,7 @@ async def test_sensors( assert state assert entry.unique_id == "12345_energy_generation" assert entry.entity_category is None - assert state.state == "500" + assert state.state == "500.0" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert ( state.attributes.get(ATTR_FRIENDLY_NAME) @@ -83,7 +83,7 @@ async def test_sensors( assert state assert entry.unique_id == "12345_power_consumption" assert entry.entity_category is None - assert state.state == "2500" + assert state.state == "2500.0" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck's Solar Farm Power consumed" @@ -98,7 +98,7 @@ async def test_sensors( assert state assert entry.unique_id == "12345_power_generation" assert entry.entity_category is None - assert state.state == "1500" + assert state.state == "1500.0" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert ( state.attributes.get(ATTR_FRIENDLY_NAME) diff --git a/tests/components/pvpc_hourly_pricing/conftest.py b/tests/components/pvpc_hourly_pricing/conftest.py index fb2c9188ce7..efe15547c13 100644 --- a/tests/components/pvpc_hourly_pricing/conftest.py +++ b/tests/components/pvpc_hourly_pricing/conftest.py @@ -10,6 +10,7 @@ from tests.common import load_fixture from tests.test_util.aiohttp import AiohttpClientMocker FIXTURE_JSON_PUBLIC_DATA_2023_01_06 = "PVPC_DATA_2023_01_06.json" +FIXTURE_JSON_ESIOS_DATA_PVPC_2023_01_06 = "PRICES_ESIOS_1001_2023_01_06.json" def check_valid_state(state, tariff: str, value=None, key_attr=None): @@ -21,7 +22,7 @@ def check_valid_state(state, tariff: str, value=None, key_attr=None): ) try: _ = float(state.state) - # safety margins for current electricity price (it shouldn't be out of [0, 0.2]) + # safety margins for current electricity price (it shouldn't be out of [0, 0.5]) assert -0.1 < float(state.state) < 0.5 assert state.attributes[ATTR_TARIFF] == tariff except ValueError: @@ -41,20 +42,42 @@ def pvpc_aioclient_mock(aioclient_mock: AiohttpClientMocker): mask_url_public = ( "https://api.esios.ree.es/archives/70/download_json?locale=es&date={0}" ) - # new format for prices >= 2021-06-01 + mask_url_esios = ( + "https://api.esios.ree.es/indicators/1001" + "?start_date={0}T00:00&end_date={0}T23:59" + ) example_day = "2023-01-06" aioclient_mock.get( mask_url_public.format(example_day), text=load_fixture(f"{DOMAIN}/{FIXTURE_JSON_PUBLIC_DATA_2023_01_06}"), ) + aioclient_mock.get( + mask_url_esios.format(example_day), + text=load_fixture(f"{DOMAIN}/{FIXTURE_JSON_ESIOS_DATA_PVPC_2023_01_06}"), + ) + # simulate missing days aioclient_mock.get( mask_url_public.format("2023-01-07"), - status=HTTPStatus.BAD_GATEWAY, + status=HTTPStatus.OK, + text='{"message":"No values for specified archive"}', + ) + aioclient_mock.get( + mask_url_esios.format("2023-01-07"), + status=HTTPStatus.OK, text=( - '{"errors":[{"code":502,"status":"502","title":"Bad response from ESIOS",' - '"detail":"There are no data for the selected filters."}]}' + '{"indicator":{"name":"Término de facturación de energía activa del ' + 'PVPC 2.0TD","short_name":"PVPC T. 2.0TD","id":1001,"composited":false,' + '"step_type":"linear","disaggregated":true,"magnitud":' + '[{"name":"Precio","id":23}],"tiempo":[{"name":"Hora","id":4}],"geos":[],' + '"values_updated_at":null,"values":[]}}' ), ) + # simulate bad authentication + aioclient_mock.get( + mask_url_esios.format("2023-01-08"), + status=HTTPStatus.UNAUTHORIZED, + text="HTTP Token: Access denied.", + ) return aioclient_mock diff --git a/tests/components/pvpc_hourly_pricing/fixtures/PRICES_ESIOS_1001_2023_01_06.json b/tests/components/pvpc_hourly_pricing/fixtures/PRICES_ESIOS_1001_2023_01_06.json new file mode 100644 index 00000000000..20ad8af3696 --- /dev/null +++ b/tests/components/pvpc_hourly_pricing/fixtures/PRICES_ESIOS_1001_2023_01_06.json @@ -0,0 +1,1007 @@ +{ + "indicator": { + "name": "Término de facturación de energía activa del PVPC 2.0TD", + "short_name": "PVPC T. 2.0TD", + "id": 1001, + "composited": false, + "step_type": "linear", + "disaggregated": true, + "magnitud": [ + { + "name": "Precio", + "id": 23 + } + ], + "tiempo": [ + { + "name": "Hora", + "id": 4 + } + ], + "geos": [ + { + "geo_id": 8741, + "geo_name": "Península" + }, + { + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "geo_id": 8745, + "geo_name": "Melilla" + } + ], + "values_updated_at": "2023-01-05T20:17:31.000+01:00", + "values": [ + { + "value": 159.69, + "datetime": "2023-01-06T00:00:00.000+01:00", + "datetime_utc": "2023-01-05T23:00:00Z", + "tz_time": "2023-01-05T23:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 159.69, + "datetime": "2023-01-06T00:00:00.000+01:00", + "datetime_utc": "2023-01-05T23:00:00Z", + "tz_time": "2023-01-05T23:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 159.69, + "datetime": "2023-01-06T00:00:00.000+01:00", + "datetime_utc": "2023-01-05T23:00:00Z", + "tz_time": "2023-01-05T23:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 159.69, + "datetime": "2023-01-06T00:00:00.000+01:00", + "datetime_utc": "2023-01-05T23:00:00Z", + "tz_time": "2023-01-05T23:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 159.69, + "datetime": "2023-01-06T00:00:00.000+01:00", + "datetime_utc": "2023-01-05T23:00:00Z", + "tz_time": "2023-01-05T23:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 155.71, + "datetime": "2023-01-06T01:00:00.000+01:00", + "datetime_utc": "2023-01-06T00:00:00Z", + "tz_time": "2023-01-06T00:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 155.71, + "datetime": "2023-01-06T01:00:00.000+01:00", + "datetime_utc": "2023-01-06T00:00:00Z", + "tz_time": "2023-01-06T00:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 155.71, + "datetime": "2023-01-06T01:00:00.000+01:00", + "datetime_utc": "2023-01-06T00:00:00Z", + "tz_time": "2023-01-06T00:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 155.71, + "datetime": "2023-01-06T01:00:00.000+01:00", + "datetime_utc": "2023-01-06T00:00:00Z", + "tz_time": "2023-01-06T00:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 155.71, + "datetime": "2023-01-06T01:00:00.000+01:00", + "datetime_utc": "2023-01-06T00:00:00Z", + "tz_time": "2023-01-06T00:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 154.41, + "datetime": "2023-01-06T02:00:00.000+01:00", + "datetime_utc": "2023-01-06T01:00:00Z", + "tz_time": "2023-01-06T01:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 154.41, + "datetime": "2023-01-06T02:00:00.000+01:00", + "datetime_utc": "2023-01-06T01:00:00Z", + "tz_time": "2023-01-06T01:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 154.41, + "datetime": "2023-01-06T02:00:00.000+01:00", + "datetime_utc": "2023-01-06T01:00:00Z", + "tz_time": "2023-01-06T01:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 154.41, + "datetime": "2023-01-06T02:00:00.000+01:00", + "datetime_utc": "2023-01-06T01:00:00Z", + "tz_time": "2023-01-06T01:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 154.41, + "datetime": "2023-01-06T02:00:00.000+01:00", + "datetime_utc": "2023-01-06T01:00:00Z", + "tz_time": "2023-01-06T01:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 139.37, + "datetime": "2023-01-06T03:00:00.000+01:00", + "datetime_utc": "2023-01-06T02:00:00Z", + "tz_time": "2023-01-06T02:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 139.37, + "datetime": "2023-01-06T03:00:00.000+01:00", + "datetime_utc": "2023-01-06T02:00:00Z", + "tz_time": "2023-01-06T02:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 139.37, + "datetime": "2023-01-06T03:00:00.000+01:00", + "datetime_utc": "2023-01-06T02:00:00Z", + "tz_time": "2023-01-06T02:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 139.37, + "datetime": "2023-01-06T03:00:00.000+01:00", + "datetime_utc": "2023-01-06T02:00:00Z", + "tz_time": "2023-01-06T02:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 139.37, + "datetime": "2023-01-06T03:00:00.000+01:00", + "datetime_utc": "2023-01-06T02:00:00Z", + "tz_time": "2023-01-06T02:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 134.02, + "datetime": "2023-01-06T04:00:00.000+01:00", + "datetime_utc": "2023-01-06T03:00:00Z", + "tz_time": "2023-01-06T03:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 134.02, + "datetime": "2023-01-06T04:00:00.000+01:00", + "datetime_utc": "2023-01-06T03:00:00Z", + "tz_time": "2023-01-06T03:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 134.02, + "datetime": "2023-01-06T04:00:00.000+01:00", + "datetime_utc": "2023-01-06T03:00:00Z", + "tz_time": "2023-01-06T03:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 134.02, + "datetime": "2023-01-06T04:00:00.000+01:00", + "datetime_utc": "2023-01-06T03:00:00Z", + "tz_time": "2023-01-06T03:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 134.02, + "datetime": "2023-01-06T04:00:00.000+01:00", + "datetime_utc": "2023-01-06T03:00:00Z", + "tz_time": "2023-01-06T03:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 140.02, + "datetime": "2023-01-06T05:00:00.000+01:00", + "datetime_utc": "2023-01-06T04:00:00Z", + "tz_time": "2023-01-06T04:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 140.02, + "datetime": "2023-01-06T05:00:00.000+01:00", + "datetime_utc": "2023-01-06T04:00:00Z", + "tz_time": "2023-01-06T04:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 140.02, + "datetime": "2023-01-06T05:00:00.000+01:00", + "datetime_utc": "2023-01-06T04:00:00Z", + "tz_time": "2023-01-06T04:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 140.02, + "datetime": "2023-01-06T05:00:00.000+01:00", + "datetime_utc": "2023-01-06T04:00:00Z", + "tz_time": "2023-01-06T04:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 140.02, + "datetime": "2023-01-06T05:00:00.000+01:00", + "datetime_utc": "2023-01-06T04:00:00Z", + "tz_time": "2023-01-06T04:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 154.05, + "datetime": "2023-01-06T06:00:00.000+01:00", + "datetime_utc": "2023-01-06T05:00:00Z", + "tz_time": "2023-01-06T05:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 154.05, + "datetime": "2023-01-06T06:00:00.000+01:00", + "datetime_utc": "2023-01-06T05:00:00Z", + "tz_time": "2023-01-06T05:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 154.05, + "datetime": "2023-01-06T06:00:00.000+01:00", + "datetime_utc": "2023-01-06T05:00:00Z", + "tz_time": "2023-01-06T05:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 154.05, + "datetime": "2023-01-06T06:00:00.000+01:00", + "datetime_utc": "2023-01-06T05:00:00Z", + "tz_time": "2023-01-06T05:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 154.05, + "datetime": "2023-01-06T06:00:00.000+01:00", + "datetime_utc": "2023-01-06T05:00:00Z", + "tz_time": "2023-01-06T05:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 163.15, + "datetime": "2023-01-06T07:00:00.000+01:00", + "datetime_utc": "2023-01-06T06:00:00Z", + "tz_time": "2023-01-06T06:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 163.15, + "datetime": "2023-01-06T07:00:00.000+01:00", + "datetime_utc": "2023-01-06T06:00:00Z", + "tz_time": "2023-01-06T06:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 163.15, + "datetime": "2023-01-06T07:00:00.000+01:00", + "datetime_utc": "2023-01-06T06:00:00Z", + "tz_time": "2023-01-06T06:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 163.15, + "datetime": "2023-01-06T07:00:00.000+01:00", + "datetime_utc": "2023-01-06T06:00:00Z", + "tz_time": "2023-01-06T06:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 163.15, + "datetime": "2023-01-06T07:00:00.000+01:00", + "datetime_utc": "2023-01-06T06:00:00Z", + "tz_time": "2023-01-06T06:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 180.5, + "datetime": "2023-01-06T08:00:00.000+01:00", + "datetime_utc": "2023-01-06T07:00:00Z", + "tz_time": "2023-01-06T07:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 180.5, + "datetime": "2023-01-06T08:00:00.000+01:00", + "datetime_utc": "2023-01-06T07:00:00Z", + "tz_time": "2023-01-06T07:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 180.5, + "datetime": "2023-01-06T08:00:00.000+01:00", + "datetime_utc": "2023-01-06T07:00:00Z", + "tz_time": "2023-01-06T07:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 180.5, + "datetime": "2023-01-06T08:00:00.000+01:00", + "datetime_utc": "2023-01-06T07:00:00Z", + "tz_time": "2023-01-06T07:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 180.5, + "datetime": "2023-01-06T08:00:00.000+01:00", + "datetime_utc": "2023-01-06T07:00:00Z", + "tz_time": "2023-01-06T07:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 174.9, + "datetime": "2023-01-06T09:00:00.000+01:00", + "datetime_utc": "2023-01-06T08:00:00Z", + "tz_time": "2023-01-06T08:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 174.9, + "datetime": "2023-01-06T09:00:00.000+01:00", + "datetime_utc": "2023-01-06T08:00:00Z", + "tz_time": "2023-01-06T08:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 174.9, + "datetime": "2023-01-06T09:00:00.000+01:00", + "datetime_utc": "2023-01-06T08:00:00Z", + "tz_time": "2023-01-06T08:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 174.9, + "datetime": "2023-01-06T09:00:00.000+01:00", + "datetime_utc": "2023-01-06T08:00:00Z", + "tz_time": "2023-01-06T08:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 174.9, + "datetime": "2023-01-06T09:00:00.000+01:00", + "datetime_utc": "2023-01-06T08:00:00Z", + "tz_time": "2023-01-06T08:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 166.47, + "datetime": "2023-01-06T10:00:00.000+01:00", + "datetime_utc": "2023-01-06T09:00:00Z", + "tz_time": "2023-01-06T09:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 166.47, + "datetime": "2023-01-06T10:00:00.000+01:00", + "datetime_utc": "2023-01-06T09:00:00Z", + "tz_time": "2023-01-06T09:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 166.47, + "datetime": "2023-01-06T10:00:00.000+01:00", + "datetime_utc": "2023-01-06T09:00:00Z", + "tz_time": "2023-01-06T09:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 166.47, + "datetime": "2023-01-06T10:00:00.000+01:00", + "datetime_utc": "2023-01-06T09:00:00Z", + "tz_time": "2023-01-06T09:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 166.47, + "datetime": "2023-01-06T10:00:00.000+01:00", + "datetime_utc": "2023-01-06T09:00:00Z", + "tz_time": "2023-01-06T09:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 152.3, + "datetime": "2023-01-06T11:00:00.000+01:00", + "datetime_utc": "2023-01-06T10:00:00Z", + "tz_time": "2023-01-06T10:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 152.3, + "datetime": "2023-01-06T11:00:00.000+01:00", + "datetime_utc": "2023-01-06T10:00:00Z", + "tz_time": "2023-01-06T10:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 152.3, + "datetime": "2023-01-06T11:00:00.000+01:00", + "datetime_utc": "2023-01-06T10:00:00Z", + "tz_time": "2023-01-06T10:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 152.3, + "datetime": "2023-01-06T11:00:00.000+01:00", + "datetime_utc": "2023-01-06T10:00:00Z", + "tz_time": "2023-01-06T10:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 152.3, + "datetime": "2023-01-06T11:00:00.000+01:00", + "datetime_utc": "2023-01-06T10:00:00Z", + "tz_time": "2023-01-06T10:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 144.54, + "datetime": "2023-01-06T12:00:00.000+01:00", + "datetime_utc": "2023-01-06T11:00:00Z", + "tz_time": "2023-01-06T11:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 144.54, + "datetime": "2023-01-06T12:00:00.000+01:00", + "datetime_utc": "2023-01-06T11:00:00Z", + "tz_time": "2023-01-06T11:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 144.54, + "datetime": "2023-01-06T12:00:00.000+01:00", + "datetime_utc": "2023-01-06T11:00:00Z", + "tz_time": "2023-01-06T11:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 144.54, + "datetime": "2023-01-06T12:00:00.000+01:00", + "datetime_utc": "2023-01-06T11:00:00Z", + "tz_time": "2023-01-06T11:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 144.54, + "datetime": "2023-01-06T12:00:00.000+01:00", + "datetime_utc": "2023-01-06T11:00:00Z", + "tz_time": "2023-01-06T11:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 132.08, + "datetime": "2023-01-06T13:00:00.000+01:00", + "datetime_utc": "2023-01-06T12:00:00Z", + "tz_time": "2023-01-06T12:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 132.08, + "datetime": "2023-01-06T13:00:00.000+01:00", + "datetime_utc": "2023-01-06T12:00:00Z", + "tz_time": "2023-01-06T12:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 132.08, + "datetime": "2023-01-06T13:00:00.000+01:00", + "datetime_utc": "2023-01-06T12:00:00Z", + "tz_time": "2023-01-06T12:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 132.08, + "datetime": "2023-01-06T13:00:00.000+01:00", + "datetime_utc": "2023-01-06T12:00:00Z", + "tz_time": "2023-01-06T12:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 132.08, + "datetime": "2023-01-06T13:00:00.000+01:00", + "datetime_utc": "2023-01-06T12:00:00Z", + "tz_time": "2023-01-06T12:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 119.6, + "datetime": "2023-01-06T14:00:00.000+01:00", + "datetime_utc": "2023-01-06T13:00:00Z", + "tz_time": "2023-01-06T13:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 119.6, + "datetime": "2023-01-06T14:00:00.000+01:00", + "datetime_utc": "2023-01-06T13:00:00Z", + "tz_time": "2023-01-06T13:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 119.6, + "datetime": "2023-01-06T14:00:00.000+01:00", + "datetime_utc": "2023-01-06T13:00:00Z", + "tz_time": "2023-01-06T13:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 119.6, + "datetime": "2023-01-06T14:00:00.000+01:00", + "datetime_utc": "2023-01-06T13:00:00Z", + "tz_time": "2023-01-06T13:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 119.6, + "datetime": "2023-01-06T14:00:00.000+01:00", + "datetime_utc": "2023-01-06T13:00:00Z", + "tz_time": "2023-01-06T13:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 108.74, + "datetime": "2023-01-06T15:00:00.000+01:00", + "datetime_utc": "2023-01-06T14:00:00Z", + "tz_time": "2023-01-06T14:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 108.74, + "datetime": "2023-01-06T15:00:00.000+01:00", + "datetime_utc": "2023-01-06T14:00:00Z", + "tz_time": "2023-01-06T14:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 108.74, + "datetime": "2023-01-06T15:00:00.000+01:00", + "datetime_utc": "2023-01-06T14:00:00Z", + "tz_time": "2023-01-06T14:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 108.74, + "datetime": "2023-01-06T15:00:00.000+01:00", + "datetime_utc": "2023-01-06T14:00:00Z", + "tz_time": "2023-01-06T14:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 108.74, + "datetime": "2023-01-06T15:00:00.000+01:00", + "datetime_utc": "2023-01-06T14:00:00Z", + "tz_time": "2023-01-06T14:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 123.79, + "datetime": "2023-01-06T16:00:00.000+01:00", + "datetime_utc": "2023-01-06T15:00:00Z", + "tz_time": "2023-01-06T15:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 123.79, + "datetime": "2023-01-06T16:00:00.000+01:00", + "datetime_utc": "2023-01-06T15:00:00Z", + "tz_time": "2023-01-06T15:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 123.79, + "datetime": "2023-01-06T16:00:00.000+01:00", + "datetime_utc": "2023-01-06T15:00:00Z", + "tz_time": "2023-01-06T15:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 123.79, + "datetime": "2023-01-06T16:00:00.000+01:00", + "datetime_utc": "2023-01-06T15:00:00Z", + "tz_time": "2023-01-06T15:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 123.79, + "datetime": "2023-01-06T16:00:00.000+01:00", + "datetime_utc": "2023-01-06T15:00:00Z", + "tz_time": "2023-01-06T15:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 166.41, + "datetime": "2023-01-06T17:00:00.000+01:00", + "datetime_utc": "2023-01-06T16:00:00Z", + "tz_time": "2023-01-06T16:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 166.41, + "datetime": "2023-01-06T17:00:00.000+01:00", + "datetime_utc": "2023-01-06T16:00:00Z", + "tz_time": "2023-01-06T16:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 166.41, + "datetime": "2023-01-06T17:00:00.000+01:00", + "datetime_utc": "2023-01-06T16:00:00Z", + "tz_time": "2023-01-06T16:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 166.41, + "datetime": "2023-01-06T17:00:00.000+01:00", + "datetime_utc": "2023-01-06T16:00:00Z", + "tz_time": "2023-01-06T16:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 166.41, + "datetime": "2023-01-06T17:00:00.000+01:00", + "datetime_utc": "2023-01-06T16:00:00Z", + "tz_time": "2023-01-06T16:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 173.49, + "datetime": "2023-01-06T18:00:00.000+01:00", + "datetime_utc": "2023-01-06T17:00:00Z", + "tz_time": "2023-01-06T17:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 173.49, + "datetime": "2023-01-06T18:00:00.000+01:00", + "datetime_utc": "2023-01-06T17:00:00Z", + "tz_time": "2023-01-06T17:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 173.49, + "datetime": "2023-01-06T18:00:00.000+01:00", + "datetime_utc": "2023-01-06T17:00:00Z", + "tz_time": "2023-01-06T17:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 173.49, + "datetime": "2023-01-06T18:00:00.000+01:00", + "datetime_utc": "2023-01-06T17:00:00Z", + "tz_time": "2023-01-06T17:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 173.49, + "datetime": "2023-01-06T18:00:00.000+01:00", + "datetime_utc": "2023-01-06T17:00:00Z", + "tz_time": "2023-01-06T17:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 186.17, + "datetime": "2023-01-06T19:00:00.000+01:00", + "datetime_utc": "2023-01-06T18:00:00Z", + "tz_time": "2023-01-06T18:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 186.17, + "datetime": "2023-01-06T19:00:00.000+01:00", + "datetime_utc": "2023-01-06T18:00:00Z", + "tz_time": "2023-01-06T18:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 186.17, + "datetime": "2023-01-06T19:00:00.000+01:00", + "datetime_utc": "2023-01-06T18:00:00Z", + "tz_time": "2023-01-06T18:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 186.17, + "datetime": "2023-01-06T19:00:00.000+01:00", + "datetime_utc": "2023-01-06T18:00:00Z", + "tz_time": "2023-01-06T18:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 186.17, + "datetime": "2023-01-06T19:00:00.000+01:00", + "datetime_utc": "2023-01-06T18:00:00Z", + "tz_time": "2023-01-06T18:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 186.11, + "datetime": "2023-01-06T20:00:00.000+01:00", + "datetime_utc": "2023-01-06T19:00:00Z", + "tz_time": "2023-01-06T19:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 186.11, + "datetime": "2023-01-06T20:00:00.000+01:00", + "datetime_utc": "2023-01-06T19:00:00Z", + "tz_time": "2023-01-06T19:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 186.11, + "datetime": "2023-01-06T20:00:00.000+01:00", + "datetime_utc": "2023-01-06T19:00:00Z", + "tz_time": "2023-01-06T19:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 186.11, + "datetime": "2023-01-06T20:00:00.000+01:00", + "datetime_utc": "2023-01-06T19:00:00Z", + "tz_time": "2023-01-06T19:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 186.11, + "datetime": "2023-01-06T20:00:00.000+01:00", + "datetime_utc": "2023-01-06T19:00:00Z", + "tz_time": "2023-01-06T19:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 178.45, + "datetime": "2023-01-06T21:00:00.000+01:00", + "datetime_utc": "2023-01-06T20:00:00Z", + "tz_time": "2023-01-06T20:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 178.45, + "datetime": "2023-01-06T21:00:00.000+01:00", + "datetime_utc": "2023-01-06T20:00:00Z", + "tz_time": "2023-01-06T20:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 178.45, + "datetime": "2023-01-06T21:00:00.000+01:00", + "datetime_utc": "2023-01-06T20:00:00Z", + "tz_time": "2023-01-06T20:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 178.45, + "datetime": "2023-01-06T21:00:00.000+01:00", + "datetime_utc": "2023-01-06T20:00:00Z", + "tz_time": "2023-01-06T20:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 178.45, + "datetime": "2023-01-06T21:00:00.000+01:00", + "datetime_utc": "2023-01-06T20:00:00Z", + "tz_time": "2023-01-06T20:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 139.37, + "datetime": "2023-01-06T22:00:00.000+01:00", + "datetime_utc": "2023-01-06T21:00:00Z", + "tz_time": "2023-01-06T21:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 139.37, + "datetime": "2023-01-06T22:00:00.000+01:00", + "datetime_utc": "2023-01-06T21:00:00Z", + "tz_time": "2023-01-06T21:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 139.37, + "datetime": "2023-01-06T22:00:00.000+01:00", + "datetime_utc": "2023-01-06T21:00:00Z", + "tz_time": "2023-01-06T21:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 139.37, + "datetime": "2023-01-06T22:00:00.000+01:00", + "datetime_utc": "2023-01-06T21:00:00Z", + "tz_time": "2023-01-06T21:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 139.37, + "datetime": "2023-01-06T22:00:00.000+01:00", + "datetime_utc": "2023-01-06T21:00:00Z", + "tz_time": "2023-01-06T21:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + }, + { + "value": 129.35, + "datetime": "2023-01-06T23:00:00.000+01:00", + "datetime_utc": "2023-01-06T22:00:00Z", + "tz_time": "2023-01-06T22:00:00.000Z", + "geo_id": 8741, + "geo_name": "Península" + }, + { + "value": 129.35, + "datetime": "2023-01-06T23:00:00.000+01:00", + "datetime_utc": "2023-01-06T22:00:00Z", + "tz_time": "2023-01-06T22:00:00.000Z", + "geo_id": 8742, + "geo_name": "Canarias" + }, + { + "value": 129.35, + "datetime": "2023-01-06T23:00:00.000+01:00", + "datetime_utc": "2023-01-06T22:00:00Z", + "tz_time": "2023-01-06T22:00:00.000Z", + "geo_id": 8743, + "geo_name": "Baleares" + }, + { + "value": 129.35, + "datetime": "2023-01-06T23:00:00.000+01:00", + "datetime_utc": "2023-01-06T22:00:00Z", + "tz_time": "2023-01-06T22:00:00.000Z", + "geo_id": 8744, + "geo_name": "Ceuta" + }, + { + "value": 129.35, + "datetime": "2023-01-06T23:00:00.000+01:00", + "datetime_utc": "2023-01-06T22:00:00Z", + "tz_time": "2023-01-06T22:00:00.000Z", + "geo_id": 8745, + "geo_name": "Melilla" + } + ] + } +} diff --git a/tests/components/pvpc_hourly_pricing/test_config_flow.py b/tests/components/pvpc_hourly_pricing/test_config_flow.py index 6560c81ebbb..950aea8e32c 100644 --- a/tests/components/pvpc_hourly_pricing/test_config_flow.py +++ b/tests/components/pvpc_hourly_pricing/test_config_flow.py @@ -4,14 +4,15 @@ from datetime import datetime, timedelta from freezegun.api import FrozenDateTimeFactory from homeassistant import config_entries, data_entry_flow -from homeassistant.components.pvpc_hourly_pricing import ( +from homeassistant.components.pvpc_hourly_pricing.const import ( ATTR_POWER, ATTR_POWER_P3, ATTR_TARIFF, + CONF_USE_API_TOKEN, DOMAIN, TARIFFS, ) -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_API_TOKEN, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util @@ -22,6 +23,7 @@ from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker _MOCK_TIME_VALID_RESPONSES = datetime(2023, 1, 6, 12, 0, tzinfo=dt_util.UTC) +_MOCK_TIME_BAD_AUTH_RESPONSES = datetime(2023, 1, 8, 12, 0, tzinfo=dt_util.UTC) async def test_config_flow( @@ -35,7 +37,7 @@ async def test_config_flow( - Check state and attributes - Check abort when trying to config another with same tariff - Check removal and add again to check state restoration - - Configure options to change power and tariff to "2.0TD" + - Configure options to introduce API Token, with bad auth and good one """ freezer.move_to(_MOCK_TIME_VALID_RESPONSES) hass.config.set_time_zone("Europe/Madrid") @@ -44,6 +46,7 @@ async def test_config_flow( ATTR_TARIFF: TARIFFS[1], ATTR_POWER: 4.6, ATTR_POWER_P3: 5.75, + CONF_USE_API_TOKEN: False, } result = await hass.config_entries.flow.async_init( @@ -107,8 +110,17 @@ async def test_config_flow( result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={ATTR_POWER: 3.0, ATTR_POWER_P3: 4.6}, + user_input={ATTR_POWER: 3.0, ATTR_POWER_P3: 4.6, CONF_USE_API_TOKEN: True}, ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "api_token" + assert pvpc_aioclient_mock.call_count == 2 + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: "good-token"} + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert pvpc_aioclient_mock.call_count == 2 await hass.async_block_till_done() state = hass.states.get("sensor.esios_pvpc") check_valid_state(state, tariff=TARIFFS[1]) @@ -125,3 +137,96 @@ async def test_config_flow( check_valid_state(state, tariff=TARIFFS[0], value="unavailable") assert "period" not in state.attributes assert pvpc_aioclient_mock.call_count == 4 + + # disable api token in options + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ATTR_POWER: 3.0, ATTR_POWER_P3: 4.6, CONF_USE_API_TOKEN: False}, + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert pvpc_aioclient_mock.call_count == 4 + await hass.async_block_till_done() + + +async def test_reauth( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + pvpc_aioclient_mock: AiohttpClientMocker, +) -> None: + """Test reauth flow for API-token mode.""" + freezer.move_to(_MOCK_TIME_BAD_AUTH_RESPONSES) + hass.config.set_time_zone("Europe/Madrid") + tst_config = { + CONF_NAME: "test", + ATTR_TARIFF: TARIFFS[1], + ATTR_POWER: 4.6, + ATTR_POWER_P3: 5.75, + CONF_USE_API_TOKEN: True, + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], tst_config + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "api_token" + assert pvpc_aioclient_mock.call_count == 0 + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: "bad-token"} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "api_token" + assert result["errors"]["base"] == "invalid_auth" + assert pvpc_aioclient_mock.call_count == 1 + + freezer.move_to(_MOCK_TIME_VALID_RESPONSES) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: "good-token"} + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + config_entry = result["result"] + assert pvpc_aioclient_mock.call_count == 3 + + # check reauth trigger with bad-auth responses + freezer.move_to(_MOCK_TIME_BAD_AUTH_RESPONSES) + async_fire_time_changed(hass, _MOCK_TIME_BAD_AUTH_RESPONSES) + await hass.async_block_till_done() + assert pvpc_aioclient_mock.call_count == 4 + + result = hass.config_entries.flow.async_progress_by_handler(DOMAIN)[0] + assert result["context"]["entry_id"] == config_entry.entry_id + assert result["context"]["source"] == config_entries.SOURCE_REAUTH + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: "bad-token"} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert pvpc_aioclient_mock.call_count == 5 + + result = hass.config_entries.flow.async_progress_by_handler(DOMAIN)[0] + assert result["context"]["entry_id"] == config_entry.entry_id + assert result["context"]["source"] == config_entries.SOURCE_REAUTH + assert result["step_id"] == "reauth_confirm" + + freezer.move_to(_MOCK_TIME_VALID_RESPONSES) + async_fire_time_changed(hass, _MOCK_TIME_VALID_RESPONSES) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: "good-token"} + ) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert pvpc_aioclient_mock.call_count == 6 + + await hass.async_block_till_done() + assert pvpc_aioclient_mock.call_count == 7 diff --git a/tests/components/python_script/test_init.py b/tests/components/python_script/test_init.py index 9326869b272..4744c065ede 100644 --- a/tests/components/python_script/test_init.py +++ b/tests/components/python_script/test_init.py @@ -367,7 +367,7 @@ async def test_service_descriptions(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.python_script.os.path.exists", return_value=True ), patch_yaml_files( - services_yaml1 + services_yaml1, ): await async_setup_component(hass, DOMAIN, {}) @@ -416,7 +416,7 @@ async def test_service_descriptions(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.python_script.os.path.exists", return_value=True ), patch_yaml_files( - services_yaml2 + services_yaml2, ): await hass.services.async_call(DOMAIN, "reload", {}, blocking=True) descriptions = await async_get_all_descriptions(hass) diff --git a/tests/components/qnap/test_config_flow.py b/tests/components/qnap/test_config_flow.py index eb77109d62e..75af07cbf8b 100644 --- a/tests/components/qnap/test_config_flow.py +++ b/tests/components/qnap/test_config_flow.py @@ -84,27 +84,3 @@ async def test_config_flow(hass: HomeAssistant, qnap_connect: MagicMock) -> None CONF_VERIFY_SSL: const.DEFAULT_VERIFY_SSL, CONF_PORT: const.DEFAULT_PORT, } - - -async def test_config_flow_import(hass: HomeAssistant) -> None: - """Test import of YAML config.""" - data = STANDARD_CONFIG - data[CONF_SSL] = const.DEFAULT_SSL - data[CONF_VERIFY_SSL] = const.DEFAULT_VERIFY_SSL - data[CONF_PORT] = const.DEFAULT_PORT - result = await hass.config_entries.flow.async_init( - const.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=data, - ) - - assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == "Test NAS name" - assert result["data"] == { - CONF_HOST: "1.2.3.4", - CONF_USERNAME: "admin", - CONF_PASSWORD: "password", - CONF_SSL: const.DEFAULT_SSL, - CONF_VERIFY_SSL: const.DEFAULT_VERIFY_SSL, - CONF_PORT: const.DEFAULT_PORT, - } diff --git a/tests/components/rainbird/test_calendar.py b/tests/components/rainbird/test_calendar.py index 04e423a399c..922ec7b0a5a 100644 --- a/tests/components/rainbird/test_calendar.py +++ b/tests/components/rainbird/test_calendar.py @@ -232,7 +232,8 @@ async def test_calendar_not_supported_by_device( @pytest.mark.parametrize( - "mock_insert_schedule_response", [([None])] # Disable success responses + "mock_insert_schedule_response", + [([None])], # Disable success responses ) async def test_no_schedule( hass: HomeAssistant, diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index db9c4c8739e..00cbefc6556 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -3,6 +3,7 @@ from __future__ import annotations from http import HTTPStatus +from typing import Any import pytest @@ -10,7 +11,7 @@ from homeassistant.components.rainbird.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_MAC from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import ( CONFIG_ENTRY_DATA, @@ -35,7 +36,7 @@ async def test_init_success( ) -> None: """Test successful setup and unload.""" - await config_entry.async_setup(hass) + await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.state == ConfigEntryState.LOADED await hass.config_entries.async_unload(config_entry.entry_id) @@ -86,7 +87,7 @@ async def test_communication_failure( config_entry_state: list[ConfigEntryState], ) -> None: """Test unable to talk to device on startup, which fails setup.""" - await config_entry.async_setup(hass) + await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.state == config_entry_state @@ -115,7 +116,7 @@ async def test_fix_unique_id( assert entries[0].unique_id is None assert entries[0].data.get(CONF_MAC) is None - await config_entry.async_setup(hass) + await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.state == ConfigEntryState.LOADED # Verify config entry now has a unique id @@ -167,7 +168,7 @@ async def test_fix_unique_id_failure( responses.insert(0, initial_response) - await config_entry.async_setup(hass) + await hass.config_entries.async_setup(config_entry.entry_id) # Config entry is loaded, but not updated assert config_entry.state == ConfigEntryState.LOADED assert config_entry.unique_id is None @@ -202,14 +203,10 @@ async def test_fix_unique_id_duplicate( responses.append(mock_json_response(WIFI_PARAMS_RESPONSE)) responses.extend(responses_copy) - await config_entry.async_setup(hass) + await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.state == ConfigEntryState.LOADED assert config_entry.unique_id == MAC_ADDRESS_UNIQUE_ID - await other_entry.async_setup(hass) - # Config entry unique id could not be updated since it already exists - assert other_entry.state == ConfigEntryState.SETUP_ERROR - assert "Unable to fix missing unique id (already exists)" in caplog.text await hass.async_block_till_done() @@ -221,39 +218,65 @@ async def test_fix_unique_id_duplicate( "config_entry_unique_id", "serial_number", "entity_unique_id", + "device_identifier", "expected_unique_id", + "expected_device_identifier", ), [ - (SERIAL_NUMBER, SERIAL_NUMBER, SERIAL_NUMBER, MAC_ADDRESS_UNIQUE_ID), + ( + SERIAL_NUMBER, + SERIAL_NUMBER, + SERIAL_NUMBER, + str(SERIAL_NUMBER), + MAC_ADDRESS_UNIQUE_ID, + MAC_ADDRESS_UNIQUE_ID, + ), ( SERIAL_NUMBER, SERIAL_NUMBER, f"{SERIAL_NUMBER}-rain-delay", + f"{SERIAL_NUMBER}-1", f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay", + f"{MAC_ADDRESS_UNIQUE_ID}-1", ), - ("0", 0, "0", MAC_ADDRESS_UNIQUE_ID), + ( + SERIAL_NUMBER, + SERIAL_NUMBER, + SERIAL_NUMBER, + SERIAL_NUMBER, + MAC_ADDRESS_UNIQUE_ID, + MAC_ADDRESS_UNIQUE_ID, + ), + ("0", 0, "0", "0", MAC_ADDRESS_UNIQUE_ID, MAC_ADDRESS_UNIQUE_ID), ( "0", 0, "0-rain-delay", + "0-1", f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay", + f"{MAC_ADDRESS_UNIQUE_ID}-1", ), ( MAC_ADDRESS_UNIQUE_ID, SERIAL_NUMBER, MAC_ADDRESS_UNIQUE_ID, MAC_ADDRESS_UNIQUE_ID, + MAC_ADDRESS_UNIQUE_ID, + MAC_ADDRESS_UNIQUE_ID, ), ( MAC_ADDRESS_UNIQUE_ID, SERIAL_NUMBER, f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay", + f"{MAC_ADDRESS_UNIQUE_ID}-1", f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay", + f"{MAC_ADDRESS_UNIQUE_ID}-1", ), ], ids=( "serial-number", "serial-number-with-suffix", + "serial-number-int", "zero-serial", "zero-serial-suffix", "new-format", @@ -264,18 +287,150 @@ async def test_fix_entity_unique_ids( hass: HomeAssistant, config_entry: MockConfigEntry, entity_unique_id: str, + device_identifier: str, expected_unique_id: str, + expected_device_identifier: str, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, ) -> None: """Test fixing entity unique ids from old unique id formats.""" - entity_registry = er.async_get(hass) entity_entry = entity_registry.async_get_or_create( DOMAIN, "number", unique_id=entity_unique_id, config_entry=config_entry ) + device_entry = device_registry.async_get_or_create( + identifiers={(DOMAIN, device_identifier)}, + config_entry_id=config_entry.entry_id, + serial_number=config_entry.data["serial_number"], + ) - await config_entry.async_setup(hass) + await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.state == ConfigEntryState.LOADED entity_entry = entity_registry.async_get(entity_entry.id) assert entity_entry assert entity_entry.unique_id == expected_unique_id + + device_entry = device_registry.async_get_device( + {(DOMAIN, expected_device_identifier)} + ) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, expected_device_identifier)} + + +@pytest.mark.parametrize( + ( + "entry1_updates", + "entry2_updates", + "expected_device_name", + "expected_disabled_by", + ), + [ + ({}, {}, None, None), + ( + { + "name_by_user": "Front Sprinkler", + }, + {}, + "Front Sprinkler", + None, + ), + ( + {}, + { + "name_by_user": "Front Sprinkler", + }, + "Front Sprinkler", + None, + ), + ( + { + "name_by_user": "Sprinkler 1", + }, + { + "name_by_user": "Sprinkler 2", + }, + "Sprinkler 2", + None, + ), + ( + { + "disabled_by": dr.DeviceEntryDisabler.USER, + }, + {}, + None, + None, + ), + ( + {}, + { + "disabled_by": dr.DeviceEntryDisabler.USER, + }, + None, + None, + ), + ( + { + "disabled_by": dr.DeviceEntryDisabler.USER, + }, + { + "disabled_by": dr.DeviceEntryDisabler.USER, + }, + None, + dr.DeviceEntryDisabler.USER, + ), + ], + ids=[ + "duplicates", + "prefer-old-name", + "prefer-new-name", + "both-names-prefers-new", + "old-disabled-prefer-new", + "new-disabled-prefer-old", + "both-disabled", + ], +) +async def test_fix_duplicate_device_ids( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entry1_updates: dict[str, Any], + entry2_updates: dict[str, Any], + expected_device_name: str | None, + expected_disabled_by: dr.DeviceEntryDisabler | None, +) -> None: + """Test fixing duplicate device ids.""" + + entry1 = device_registry.async_get_or_create( + identifiers={(DOMAIN, str(SERIAL_NUMBER))}, + config_entry_id=config_entry.entry_id, + serial_number=config_entry.data["serial_number"], + ) + device_registry.async_update_device(entry1.id, **entry1_updates) + + entry2 = device_registry.async_get_or_create( + identifiers={(DOMAIN, MAC_ADDRESS_UNIQUE_ID)}, + config_entry_id=config_entry.entry_id, + serial_number=config_entry.data["serial_number"], + ) + device_registry.async_update_device(entry2.id, **entry2_updates) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert len(device_entries) == 2 + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state == ConfigEntryState.LOADED + + # Only the device with the new format exists + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert len(device_entries) == 1 + + device_entry = device_registry.async_get_device({(DOMAIN, MAC_ADDRESS_UNIQUE_ID)}) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, MAC_ADDRESS_UNIQUE_ID)} + assert device_entry.name_by_user == expected_device_name + assert device_entry.disabled_by == expected_disabled_by diff --git a/tests/components/rainmachine/conftest.py b/tests/components/rainmachine/conftest.py index 685f307d197..2697e908c94 100644 --- a/tests/components/rainmachine/conftest.py +++ b/tests/components/rainmachine/conftest.py @@ -134,7 +134,8 @@ async def setup_rainmachine_fixture(hass, client, config): ), patch( "homeassistant.components.rainmachine.config_flow.Client", return_value=client ), patch( - "homeassistant.components.rainmachine.PLATFORMS", [] + "homeassistant.components.rainmachine.PLATFORMS", + [], ): assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() diff --git a/tests/components/rdw/conftest.py b/tests/components/rdw/conftest.py index 4be17f00264..5fe40b0b497 100644 --- a/tests/components/rdw/conftest.py +++ b/tests/components/rdw/conftest.py @@ -38,7 +38,7 @@ def mock_rdw_config_flow() -> Generator[None, MagicMock, None]: "homeassistant.components.rdw.config_flow.RDW", autospec=True ) as rdw_mock: rdw = rdw_mock.return_value - rdw.vehicle.return_value = Vehicle.parse_raw(load_fixture("rdw/11ZKZ3.json")) + rdw.vehicle.return_value = Vehicle.from_json(load_fixture("rdw/11ZKZ3.json")) yield rdw @@ -49,7 +49,7 @@ def mock_rdw(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None] if hasattr(request, "param") and request.param: fixture = request.param - vehicle = Vehicle.parse_raw(load_fixture(fixture)) + vehicle = Vehicle.from_json(load_fixture(fixture)) with patch("homeassistant.components.rdw.RDW", autospec=True) as rdw_mock: rdw = rdw_mock.return_value rdw.vehicle.return_value = vehicle diff --git a/tests/components/rdw/snapshots/test_diagnostics.ambr b/tests/components/rdw/snapshots/test_diagnostics.ambr index 6da03b67245..cc2a344025a 100644 --- a/tests/components/rdw/snapshots/test_diagnostics.ambr +++ b/tests/components/rdw/snapshots/test_diagnostics.ambr @@ -1,30 +1,30 @@ # serializer version: 1 # name: test_diagnostics dict({ - 'apk_expiration': '2022-01-04', - 'ascription_date': '2021-11-04', - 'ascription_possible': True, - 'brand': 'Skoda', - 'energy_label': 'A', - 'engine_capacity': 999, - 'exported': False, - 'first_admission': '2013-01-04', - 'interior': 'hatchback', - 'last_odometer_registration_year': 2021, - 'liability_insured': False, - 'license_plate': '11ZKZ3', - 'list_price': 10697, - 'mass_driveable': 940, - 'mass_empty': 840, - 'model': 'Citigo', - 'number_of_cylinders': 3, - 'number_of_doors': 0, - 'number_of_seats': 4, - 'number_of_wheelchair_seats': 0, - 'number_of_wheels': 4, - 'odometer_judgement': 'Logisch', - 'pending_recall': False, - 'taxi': None, - 'vehicle_type': 'Personenauto', + 'aantal_cilinders': 3, + 'aantal_deuren': 0, + 'aantal_rolstoelplaatsen': 0, + 'aantal_wielen': 4, + 'aantal_zitplaatsen': 4, + 'catalogusprijs': 10697, + 'cilinderinhoud': 999, + 'datum_eerste_toelating': '20130104', + 'datum_tenaamstelling': '20211104', + 'export_indicator': 'Nee', + 'handelsbenaming': 'Citigo', + 'inrichting': 'hatchback', + 'jaar_laatste_registratie_tellerstand': 2021, + 'kenteken': '11ZKZ3', + 'massa_ledig_voertuig': 840, + 'massa_rijklaar': 940, + 'merk': 'Skoda', + 'openstaande_terugroepactie_indicator': 'Nee', + 'taxi_indicator': None, + 'tellerstandoordeel': 'Logisch', + 'tenaamstellen_mogelijk': 'Ja', + 'vervaldatum_apk': '20220104', + 'voertuigsoort': 'Personenauto', + 'wam_verzekerd': 'Nee', + 'zuinigheidslabel': 'A', }) # --- diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index a982eeb39be..d0ed6f15d43 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -412,17 +412,11 @@ def old_db_schema(schema_version_postfix: str) -> Iterator[None]: recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object( core, "EventTypes", old_db_schema.EventTypes - ), patch.object( - core, "EventData", old_db_schema.EventData - ), patch.object( + ), patch.object(core, "EventData", old_db_schema.EventData), patch.object( core, "States", old_db_schema.States - ), patch.object( - core, "Events", old_db_schema.Events - ), patch.object( + ), patch.object(core, "Events", old_db_schema.Events), patch.object( core, "StateAttributes", old_db_schema.StateAttributes - ), patch.object( - core, "EntityIDMigrationTask", core.RecorderTask - ), patch( + ), patch.object(core, "EntityIDMigrationTask", core.RecorderTask), patch( CREATE_ENGINE_TARGET, new=partial( create_engine_test_for_schema_version_postfix, diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index 852419559b2..b9d0801d788 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -85,17 +85,11 @@ def db_schema_32(): recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object( core, "EventTypes", old_db_schema.EventTypes - ), patch.object( - core, "EventData", old_db_schema.EventData - ), patch.object( + ), patch.object(core, "EventData", old_db_schema.EventData), patch.object( core, "States", old_db_schema.States - ), patch.object( - core, "Events", old_db_schema.Events - ), patch.object( + ), patch.object(core, "Events", old_db_schema.Events), patch.object( core, "StateAttributes", old_db_schema.StateAttributes - ), patch.object( - core, "EntityIDMigrationTask", core.RecorderTask - ), patch( + ), patch.object(core, "EntityIDMigrationTask", core.RecorderTask), patch( CREATE_ENGINE_TARGET, new=_create_engine_test ): yield diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 4faa8dc7e8a..1696c9018b4 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -244,9 +244,7 @@ async def test_purge_old_states_encounters_temporary_mysql_error( ) as sleep_mock, patch( "homeassistant.components.recorder.purge._purge_old_recorder_runs", side_effect=[mysql_exception, None], - ), patch.object( - instance.engine.dialect, "name", "mysql" - ): + ), patch.object(instance.engine.dialect, "name", "mysql"): await hass.services.async_call(recorder.DOMAIN, SERVICE_PURGE, {"keep_days": 0}) await hass.async_block_till_done() await async_wait_recording_done(hass) diff --git a/tests/components/recorder/test_purge_v32_schema.py b/tests/components/recorder/test_purge_v32_schema.py index f386fd19e36..e8f9130165f 100644 --- a/tests/components/recorder/test_purge_v32_schema.py +++ b/tests/components/recorder/test_purge_v32_schema.py @@ -212,9 +212,7 @@ async def test_purge_old_states_encounters_temporary_mysql_error( ) as sleep_mock, patch( "homeassistant.components.recorder.purge._purge_old_recorder_runs", side_effect=[mysql_exception, None], - ), patch.object( - instance.engine.dialect, "name", "mysql" - ): + ), patch.object(instance.engine.dialect, "name", "mysql"): await hass.services.async_call(recorder.DOMAIN, SERVICE_PURGE, {"keep_days": 0}) await hass.async_block_till_done() await async_wait_recording_done(hass) diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index a7b15a7f12d..0a30895adc9 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -25,6 +25,7 @@ from homeassistant.components.recorder.models import ( process_timestamp, ) from homeassistant.components.recorder.util import ( + chunked_or_all, end_incomplete_runs, is_second_sunday, resolve_period, @@ -1023,3 +1024,24 @@ async def test_resolve_period(hass: HomeAssistant) -> None: } } ) == (now - timedelta(hours=1, minutes=25), now - timedelta(minutes=25)) + + +def test_chunked_or_all(): + """Test chunked_or_all can iterate chunk sizes larger than the passed in collection.""" + all = [] + incoming = (1, 2, 3, 4) + for chunk in chunked_or_all(incoming, 2): + assert len(chunk) == 2 + all.extend(chunk) + assert all == [1, 2, 3, 4] + + all = [] + incoming = (1, 2, 3, 4) + for chunk in chunked_or_all(incoming, 5): + assert len(chunk) == 4 + # Verify the chunk is the same object as the incoming + # collection since we want to avoid copying the collection + # if we don't need to + assert chunk is incoming + all.extend(chunk) + assert all == [1, 2, 3, 4] diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 98f401e45d8..b11cc67707f 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -98,13 +98,9 @@ async def test_migrate_times(caplog: pytest.LogCaptureFixture, tmp_path: Path) - recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object( core, "EventTypes", old_db_schema.EventTypes - ), patch.object( - core, "EventData", old_db_schema.EventData - ), patch.object( + ), patch.object(core, "EventData", old_db_schema.EventData), patch.object( core, "States", old_db_schema.States - ), patch.object( - core, "Events", old_db_schema.Events - ), patch( + ), patch.object(core, "Events", old_db_schema.Events), patch( CREATE_ENGINE_TARGET, new=_create_engine_test ), patch( "homeassistant.components.recorder.Recorder._migrate_events_context_ids", @@ -269,13 +265,9 @@ async def test_migrate_can_resume_entity_id_post_migration( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object( core, "EventTypes", old_db_schema.EventTypes - ), patch.object( - core, "EventData", old_db_schema.EventData - ), patch.object( + ), patch.object(core, "EventData", old_db_schema.EventData), patch.object( core, "States", old_db_schema.States - ), patch.object( - core, "Events", old_db_schema.Events - ), patch( + ), patch.object(core, "Events", old_db_schema.Events), patch( CREATE_ENGINE_TARGET, new=_create_engine_test ), patch( "homeassistant.components.recorder.Recorder._migrate_events_context_ids", diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index b371d69fe5f..323b81211d7 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -2227,9 +2227,7 @@ async def test_recorder_info_migration_queue_exhausted( ), patch( "homeassistant.components.recorder.core.create_engine", new=create_engine_test, - ), patch.object( - recorder.core, "MAX_QUEUE_BACKLOG_MIN_VALUE", 1 - ), patch.object( + ), patch.object(recorder.core, "MAX_QUEUE_BACKLOG_MIN_VALUE", 1), patch.object( recorder.core, "QUEUE_PERCENTAGE_ALLOWED_AVAILABLE_MEMORY", 0 ), patch( "homeassistant.components.recorder.migration._apply_update", diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 3efc1e481df..75d2dc0c661 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -19,8 +19,11 @@ TEST_USERNAME2 = "username" TEST_PASSWORD = "password" TEST_PASSWORD2 = "new_password" TEST_MAC = "ab:cd:ef:gh:ij:kl" +TEST_MAC2 = "12:34:56:78:9a:bc" +TEST_UID = "ABC1234567D89EFG" TEST_PORT = 1234 TEST_NVR_NAME = "test_reolink_name" +TEST_NVR_NAME2 = "test2_reolink_name" TEST_USE_HTTPS = True @@ -51,6 +54,7 @@ def reolink_connect_class( host_mock.unsubscribe.return_value = True host_mock.logout.return_value = True host_mock.mac_address = TEST_MAC + host_mock.uid = TEST_UID host_mock.onvif_enabled = True host_mock.rtmp_enabled = True host_mock.rtsp_enabled = True @@ -59,14 +63,30 @@ def reolink_connect_class( host_mock.use_https = TEST_USE_HTTPS host_mock.is_admin = True host_mock.user_level = "admin" + host_mock.protocol = "rtsp" + host_mock.channels = [0] + host_mock.stream_channels = [0] host_mock.sw_version_update_required = False host_mock.hardware_version = "IPC_00000" host_mock.sw_version = "v1.0.0.0.0.0000" host_mock.manufacturer = "Reolink" host_mock.model = "RLC-123" + host_mock.camera_model.return_value = "RLC-123" + host_mock.camera_name.return_value = TEST_NVR_NAME + host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000" host_mock.session_active = True host_mock.timeout = 60 host_mock.renewtimer.return_value = 600 + host_mock.wifi_connection = False + host_mock.wifi_signal = None + host_mock.whiteled_mode_list.return_value = [] + host_mock.zoom_range.return_value = { + "zoom": {"pos": {"min": 0, "max": 100}}, + "focus": {"pos": {"min": 0, "max": 100}}, + } + host_mock.capabilities = {"Host": ["RTSP"], "0": ["motion_detection"]} + host_mock.checked_api_versions = {"GetEvents": 1} + host_mock.abilities = {"abilityChn": [{"aiTrack": {"permit": 0, "ver": 0}}]} yield host_mock_class diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..604a9364320 --- /dev/null +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'HTTP(S) port': 1234, + 'HTTPS': True, + 'IPC cams': dict({ + '0': dict({ + 'firmware version': 'v1.1.0.0.0.0000', + 'model': 'RLC-123', + }), + }), + 'ONVIF enabled': True, + 'RTMP enabled': True, + 'RTSP enabled': True, + 'WiFi connection': False, + 'WiFi signal': None, + 'abilities': dict({ + 'abilityChn': list([ + dict({ + 'aiTrack': dict({ + 'permit': 0, + 'ver': 0, + }), + }), + ]), + }), + 'api versions': dict({ + 'GetEvents': 1, + }), + 'capabilities': dict({ + '0': list([ + 'motion_detection', + ]), + 'Host': list([ + 'RTSP', + ]), + }), + 'channels': list([ + 0, + ]), + 'event connection': 'Fast polling', + 'firmware version': 'v1.0.0.0.0.0000', + 'hardware version': 'IPC_00000', + 'model': 'RLC-123', + 'stream channels': list([ + 0, + ]), + 'stream protocol': 'rtsp', + }) +# --- diff --git a/tests/components/reolink/test_diagnostics.py b/tests/components/reolink/test_diagnostics.py new file mode 100644 index 00000000000..57b474c13ad --- /dev/null +++ b/tests/components/reolink/test_diagnostics.py @@ -0,0 +1,25 @@ +"""Test Reolink diagnostics.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test Reolink diagnostics.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert diag == snapshot diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py new file mode 100644 index 00000000000..7fe3570564a --- /dev/null +++ b/tests/components/reolink/test_media_source.py @@ -0,0 +1,288 @@ +"""Tests for the Reolink media_source platform.""" +from datetime import datetime, timedelta +import logging +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from reolink_aio.exceptions import ReolinkError + +from homeassistant.components.media_source import ( + DOMAIN as MEDIA_SOURCE_DOMAIN, + URI_SCHEME, + async_browse_media, + async_resolve_media, +) +from homeassistant.components.media_source.error import Unresolvable +from homeassistant.components.reolink import const +from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL +from homeassistant.components.reolink.const import DOMAIN +from homeassistant.components.stream import DOMAIN as MEDIA_STREAM_DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import format_mac +from homeassistant.setup import async_setup_component + +from .conftest import ( + TEST_HOST2, + TEST_MAC2, + TEST_NVR_NAME, + TEST_NVR_NAME2, + TEST_PASSWORD2, + TEST_PORT, + TEST_USE_HTTPS, + TEST_USERNAME2, +) + +from tests.common import MockConfigEntry + +TEST_YEAR = 2023 +TEST_MONTH = 11 +TEST_DAY = 14 +TEST_DAY2 = 15 +TEST_HOUR = 13 +TEST_MINUTE = 12 +TEST_FILE_NAME = f"{TEST_YEAR}{TEST_MONTH}{TEST_DAY}{TEST_HOUR}{TEST_MINUTE}00" +TEST_STREAM = "main" +TEST_CHANNEL = "0" + +TEST_MIME_TYPE = "application/x-mpegURL" +TEST_URL = "http:test_url" + + +@pytest.fixture(autouse=True) +async def setup_component(hass: HomeAssistant) -> None: + """Set up component.""" + assert await async_setup_component(hass, MEDIA_SOURCE_DOMAIN, {}) + assert await async_setup_component(hass, MEDIA_STREAM_DOMAIN, {}) + + +async def test_resolve( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test resolving Reolink media items.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE, TEST_URL) + caplog.set_level(logging.DEBUG) + + file_id = ( + f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}" + ) + + play_media = await async_resolve_media(hass, f"{URI_SCHEME}{DOMAIN}/{file_id}") + + assert play_media.mime_type == TEST_MIME_TYPE + + +async def test_browsing( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test browsing the Reolink three.""" + entry_id = config_entry.entry_id + reolink_connect.api_version.return_value = 1 + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): + assert await hass.config_entries.async_setup(entry_id) is True + await hass.async_block_till_done() + + entries = dr.async_entries_for_config_entry(device_registry, entry_id) + assert len(entries) > 0 + device_registry.async_update_device(entries[0].id, name_by_user="Cam new name") + + caplog.set_level(logging.DEBUG) + + # browse root + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") + + browse_root_id = f"CAM|{entry_id}|{TEST_CHANNEL}" + assert browse.domain == DOMAIN + assert browse.title == "Reolink" + assert browse.identifier is None + assert browse.children[0].identifier == browse_root_id + + # browse resolution select + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_root_id}") + + browse_resolution_id = f"RESs|{entry_id}|{TEST_CHANNEL}" + browse_res_sub_id = f"RES|{entry_id}|{TEST_CHANNEL}|sub" + browse_res_main_id = f"RES|{entry_id}|{TEST_CHANNEL}|main" + assert browse.domain == DOMAIN + assert browse.title == TEST_NVR_NAME + assert browse.identifier == browse_resolution_id + assert browse.children[0].identifier == browse_res_sub_id + assert browse.children[1].identifier == browse_res_main_id + + # browse camera recording days + mock_status = MagicMock() + mock_status.year = TEST_YEAR + mock_status.month = TEST_MONTH + mock_status.days = (TEST_DAY, TEST_DAY2) + reolink_connect.request_vod_files.return_value = ([mock_status], []) + + browse = await async_browse_media( + hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_main_id}" + ) + + browse_days_id = f"DAYS|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}" + browse_day_0_id = f"DAY|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY}" + browse_day_1_id = f"DAY|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY2}" + assert browse.domain == DOMAIN + assert browse.title == f"{TEST_NVR_NAME} High res." + assert browse.identifier == browse_days_id + assert browse.children[0].identifier == browse_day_0_id + assert browse.children[1].identifier == browse_day_1_id + + # browse camera recording files on day + mock_vod_file = MagicMock() + mock_vod_file.start_time = datetime( + TEST_YEAR, TEST_MONTH, TEST_DAY, TEST_HOUR, TEST_MINUTE + ) + mock_vod_file.duration = timedelta(minutes=15) + mock_vod_file.file_name = TEST_FILE_NAME + reolink_connect.request_vod_files.return_value = ([mock_status], [mock_vod_file]) + + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_day_0_id}") + + browse_files_id = f"FILES|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}" + browse_file_id = f"FILE|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}" + assert browse.domain == DOMAIN + assert ( + browse.title == f"{TEST_NVR_NAME} High res. {TEST_YEAR}/{TEST_MONTH}/{TEST_DAY}" + ) + assert browse.identifier == browse_files_id + assert browse.children[0].identifier == browse_file_id + + +async def test_browsing_unsupported_encoding( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test browsing a Reolink camera with unsupported stream encoding.""" + entry_id = config_entry.entry_id + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): + assert await hass.config_entries.async_setup(entry_id) is True + await hass.async_block_till_done() + + browse_root_id = f"CAM|{entry_id}|{TEST_CHANNEL}" + + # browse resolution select/camera recording days when main encoding unsupported + mock_status = MagicMock() + mock_status.year = TEST_YEAR + mock_status.month = TEST_MONTH + mock_status.days = (TEST_DAY, TEST_DAY2) + reolink_connect.request_vod_files.return_value = ([mock_status], []) + reolink_connect.time.return_value = None + reolink_connect.get_encoding.return_value = "h265" + + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_root_id}") + + browse_days_id = f"DAYS|{entry_id}|{TEST_CHANNEL}|sub" + browse_day_0_id = ( + f"DAY|{entry_id}|{TEST_CHANNEL}|sub|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY}" + ) + browse_day_1_id = ( + f"DAY|{entry_id}|{TEST_CHANNEL}|sub|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY2}" + ) + assert browse.domain == DOMAIN + assert browse.title == f"{TEST_NVR_NAME} Low res." + assert browse.identifier == browse_days_id + assert browse.children[0].identifier == browse_day_0_id + assert browse.children[1].identifier == browse_day_1_id + + +async def test_browsing_rec_playback_unsupported( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test browsing a Reolink camera which does not support playback of recordings.""" + reolink_connect.api_version.return_value = 0 + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): + assert await hass.config_entries.async_setup(config_entry.entry_id) is True + await hass.async_block_till_done() + + # browse root + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") + + assert browse.domain == DOMAIN + assert browse.title == "Reolink" + assert browse.identifier is None + assert browse.children == [] + + +async def test_browsing_errors( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test browsing a Reolink camera errors.""" + reolink_connect.api_version.return_value = 1 + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): + assert await hass.config_entries.async_setup(config_entry.entry_id) is True + await hass.async_block_till_done() + + # browse root + with pytest.raises(Unresolvable): + await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/UNKNOWN") + with pytest.raises(Unresolvable): + await async_resolve_media(hass, f"{URI_SCHEME}{DOMAIN}/UNKNOWN") + + +async def test_browsing_not_loaded( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test browsing a Reolink camera integration which is not loaded.""" + reolink_connect.api_version.return_value = 1 + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): + assert await hass.config_entries.async_setup(config_entry.entry_id) is True + await hass.async_block_till_done() + + reolink_connect.get_host_data = AsyncMock(side_effect=ReolinkError("Test error")) + config_entry2 = MockConfigEntry( + domain=const.DOMAIN, + unique_id=format_mac(TEST_MAC2), + data={ + CONF_HOST: TEST_HOST2, + CONF_USERNAME: TEST_USERNAME2, + CONF_PASSWORD: TEST_PASSWORD2, + CONF_PORT: TEST_PORT, + const.CONF_USE_HTTPS: TEST_USE_HTTPS, + }, + options={ + const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + }, + title=TEST_NVR_NAME2, + ) + config_entry2.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry2.entry_id) is False + await hass.async_block_till_done() + + # browse root + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") + + assert browse.domain == DOMAIN + assert browse.title == "Reolink" + assert browse.identifier is None + assert len(browse.children) == 1 diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index d57cd41aa10..df90af44e73 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -61,7 +61,10 @@ async def test_setup_missing_config( assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() assert_setup_component(0, SWITCH_DOMAIN) - assert "Invalid config for [switch.rest]: required key not provided" in caplog.text + assert ( + "Invalid config for 'switch.rest': required key 'resource' not provided" + in caplog.text + ) async def test_setup_missing_schema( @@ -72,7 +75,7 @@ async def test_setup_missing_schema( assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() assert_setup_component(0, SWITCH_DOMAIN) - assert "Invalid config for [switch.rest]: invalid url" in caplog.text + assert "Invalid config for 'switch.rest': invalid url" in caplog.text @respx.mock diff --git a/tests/components/ring/conftest.py b/tests/components/ring/conftest.py index 2b6edf86132..e9800393835 100644 --- a/tests/components/ring/conftest.py +++ b/tests/components/ring/conftest.py @@ -1,13 +1,74 @@ """Configuration for Ring tests.""" +from collections.abc import Generator import re +from unittest.mock import AsyncMock, Mock, patch import pytest import requests_mock -from tests.common import load_fixture +from homeassistant.components.ring import DOMAIN +from homeassistant.const import CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture from tests.components.light.conftest import mock_light_profiles # noqa: F401 +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ring.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_ring_auth(): + """Mock ring_doorbell.Auth.""" + with patch("ring_doorbell.Auth", autospec=True) as mock_ring_auth: + mock_ring_auth.return_value.fetch_token.return_value = { + "access_token": "mock-token" + } + yield mock_ring_auth.return_value + + +@pytest.fixture +def mock_ring(): + """Mock ring_doorbell.Ring.""" + with patch("ring_doorbell.Ring", autospec=True) as mock_ring: + yield mock_ring.return_value + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock ConfigEntry.""" + return MockConfigEntry( + title="Ring", + domain=DOMAIN, + data={ + CONF_USERNAME: "foo@bar.com", + "token": {"access_token": "mock-token"}, + }, + unique_id="foo@bar.com", + ) + + +@pytest.fixture +async def mock_added_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ring_auth: Mock, + mock_ring: Mock, +) -> MockConfigEntry: + """Mock ConfigEntry that's been added to HA.""" + 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 DOMAIN in hass.config_entries.async_domains() + return mock_config_entry + + @pytest.fixture(name="requests_mock") def requests_mock_fixture(): """Fixture to provide a requests mocker.""" @@ -52,5 +113,11 @@ def requests_mock_fixture(): re.compile(r"https:\/\/api\.ring\.com\/clients_api\/chimes\/\d+\/health"), text=load_fixture("chime_health_attrs.json", "ring"), ) - + mock.get( + re.compile( + r"https:\/\/api\.ring\.com\/clients_api\/dings\/\d+\/share/play" + ), + status_code=200, + json={"url": "http://127.0.0.1/foo"}, + ) yield mock diff --git a/tests/components/ring/snapshots/test_diagnostics.ambr b/tests/components/ring/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..64e753ba2b3 --- /dev/null +++ b/tests/components/ring/snapshots/test_diagnostics.ambr @@ -0,0 +1,579 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'device_data': list([ + dict({ + 'address': '**REDACTED**', + 'alerts': dict({ + 'connection': 'online', + }), + 'description': '**REDACTED**', + 'device_id': '**REDACTED**', + 'do_not_disturb': dict({ + 'seconds_left': 0, + }), + 'features': dict({ + 'ringtones_enabled': True, + }), + 'firmware_version': '1.2.3', + 'id': '**REDACTED**', + 'kind': 'chime', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'owned': True, + 'owner': dict({ + 'email': '**REDACTED**', + 'first_name': '**REDACTED**', + 'id': '**REDACTED**', + 'last_name': '**REDACTED**', + }), + 'settings': dict({ + 'ding_audio_id': None, + 'ding_audio_user_id': None, + 'motion_audio_id': None, + 'motion_audio_user_id': None, + 'volume': 2, + }), + 'time_zone': 'America/New_York', + }), + dict({ + 'address': '**REDACTED**', + 'alerts': dict({ + 'connection': 'online', + }), + 'battery_life': 4081, + 'description': '**REDACTED**', + 'device_id': '**REDACTED**', + 'external_connection': False, + 'features': dict({ + 'advanced_motion_enabled': False, + 'motion_message_enabled': False, + 'motions_enabled': True, + 'people_only_enabled': False, + 'shadow_correction_enabled': False, + 'show_recordings': True, + }), + 'firmware_version': '1.4.26', + 'id': '**REDACTED**', + 'kind': 'lpd_v1', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'motion_snooze': None, + 'owned': True, + 'owner': dict({ + 'email': '**REDACTED**', + 'first_name': '**REDACTED**', + 'id': '**REDACTED**', + 'last_name': '**REDACTED**', + }), + 'settings': dict({ + 'chime_settings': dict({ + 'duration': 3, + 'enable': True, + 'type': 0, + }), + 'doorbell_volume': 1, + 'enable_vod': True, + 'live_view_preset_profile': 'highest', + 'live_view_presets': list([ + 'low', + 'middle', + 'high', + 'highest', + ]), + 'motion_announcement': False, + 'motion_snooze_preset_profile': 'low', + 'motion_snooze_presets': list([ + 'null', + 'low', + 'medium', + 'high', + ]), + }), + 'subscribed': True, + 'subscribed_motions': True, + 'time_zone': 'America/New_York', + }), + dict({ + 'address': '**REDACTED**', + 'alerts': dict({ + 'connection': 'online', + }), + 'battery_life': 80, + 'description': '**REDACTED**', + 'device_id': '**REDACTED**', + 'external_connection': False, + 'features': dict({ + 'advanced_motion_enabled': False, + 'motion_message_enabled': False, + 'motions_enabled': True, + 'night_vision_enabled': False, + 'people_only_enabled': False, + 'shadow_correction_enabled': False, + 'show_recordings': True, + }), + 'firmware_version': '1.9.3', + 'id': '**REDACTED**', + 'kind': 'hp_cam_v1', + 'latitude': '**REDACTED**', + 'led_status': 'off', + 'location_id': None, + 'longitude': '**REDACTED**', + 'motion_snooze': dict({ + 'scheduled': True, + }), + 'night_mode_status': 'false', + 'owned': True, + 'owner': dict({ + 'email': '**REDACTED**', + 'first_name': '**REDACTED**', + 'id': '**REDACTED**', + 'last_name': '**REDACTED**', + }), + 'ring_cam_light_installed': 'false', + 'ring_id': None, + 'settings': dict({ + 'chime_settings': dict({ + 'duration': 10, + 'enable': True, + 'type': 0, + }), + 'doorbell_volume': 11, + 'enable_vod': True, + 'floodlight_settings': dict({ + 'duration': 30, + 'priority': 0, + }), + 'light_schedule_settings': dict({ + 'end_hour': 0, + 'end_minute': 0, + 'start_hour': 0, + 'start_minute': 0, + }), + 'live_view_preset_profile': 'highest', + 'live_view_presets': list([ + 'low', + 'middle', + 'high', + 'highest', + ]), + 'motion_announcement': False, + 'motion_snooze_preset_profile': 'low', + 'motion_snooze_presets': list([ + 'none', + 'low', + 'medium', + 'high', + ]), + 'motion_zones': dict({ + 'active_motion_filter': 1, + 'advanced_object_settings': dict({ + 'human_detection_confidence': dict({ + 'day': 0.7, + 'night': 0.7, + }), + 'motion_zone_overlap': dict({ + 'day': 0.1, + 'night': 0.2, + }), + 'object_size_maximum': dict({ + 'day': 0.8, + 'night': 0.8, + }), + 'object_size_minimum': dict({ + 'day': 0.03, + 'night': 0.05, + }), + 'object_time_overlap': dict({ + 'day': 0.1, + 'night': 0.6, + }), + }), + 'enable_audio': False, + 'pir_settings': dict({ + 'sensitivity1': 1, + 'sensitivity2': 1, + 'sensitivity3': 1, + 'zone_mask': 6, + }), + 'sensitivity': 5, + 'zone1': dict({ + 'name': 'Zone 1', + 'state': 2, + 'vertex1': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex2': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex3': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex4': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex5': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex6': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex7': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex8': dict({ + 'x': 0.0, + 'y': 0.0, + }), + }), + 'zone2': dict({ + 'name': 'Zone 2', + 'state': 2, + 'vertex1': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex2': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex3': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex4': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex5': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex6': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex7': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex8': dict({ + 'x': 0.0, + 'y': 0.0, + }), + }), + 'zone3': dict({ + 'name': 'Zone 3', + 'state': 2, + 'vertex1': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex2': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex3': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex4': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex5': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex6': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex7': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex8': dict({ + 'x': 0.0, + 'y': 0.0, + }), + }), + }), + 'pir_motion_zones': list([ + 0, + 1, + 1, + ]), + 'pir_settings': dict({ + 'sensitivity1': 1, + 'sensitivity2': 1, + 'sensitivity3': 1, + 'zone_mask': 6, + }), + 'stream_setting': 0, + 'video_settings': dict({ + 'ae_level': 0, + 'birton': None, + 'brightness': 0, + 'contrast': 64, + 'saturation': 80, + }), + }), + 'siren_status': dict({ + 'seconds_remaining': 0, + }), + 'stolen': False, + 'subscribed': True, + 'subscribed_motions': True, + 'time_zone': 'America/New_York', + }), + dict({ + 'address': '**REDACTED**', + 'alerts': dict({ + 'connection': 'online', + }), + 'battery_life': 80, + 'description': '**REDACTED**', + 'device_id': '**REDACTED**', + 'external_connection': False, + 'features': dict({ + 'advanced_motion_enabled': False, + 'motion_message_enabled': False, + 'motions_enabled': True, + 'night_vision_enabled': False, + 'people_only_enabled': False, + 'shadow_correction_enabled': False, + 'show_recordings': True, + }), + 'firmware_version': '1.9.3', + 'id': '**REDACTED**', + 'kind': 'hp_cam_v1', + 'latitude': '**REDACTED**', + 'led_status': 'on', + 'location_id': None, + 'longitude': '**REDACTED**', + 'motion_snooze': dict({ + 'scheduled': True, + }), + 'night_mode_status': 'false', + 'owned': True, + 'owner': dict({ + 'email': '**REDACTED**', + 'first_name': '**REDACTED**', + 'id': '**REDACTED**', + 'last_name': '**REDACTED**', + }), + 'ring_cam_light_installed': 'false', + 'ring_id': None, + 'settings': dict({ + 'chime_settings': dict({ + 'duration': 10, + 'enable': True, + 'type': 0, + }), + 'doorbell_volume': 11, + 'enable_vod': True, + 'floodlight_settings': dict({ + 'duration': 30, + 'priority': 0, + }), + 'light_schedule_settings': dict({ + 'end_hour': 0, + 'end_minute': 0, + 'start_hour': 0, + 'start_minute': 0, + }), + 'live_view_preset_profile': 'highest', + 'live_view_presets': list([ + 'low', + 'middle', + 'high', + 'highest', + ]), + 'motion_announcement': False, + 'motion_snooze_preset_profile': 'low', + 'motion_snooze_presets': list([ + 'none', + 'low', + 'medium', + 'high', + ]), + 'motion_zones': dict({ + 'active_motion_filter': 1, + 'advanced_object_settings': dict({ + 'human_detection_confidence': dict({ + 'day': 0.7, + 'night': 0.7, + }), + 'motion_zone_overlap': dict({ + 'day': 0.1, + 'night': 0.2, + }), + 'object_size_maximum': dict({ + 'day': 0.8, + 'night': 0.8, + }), + 'object_size_minimum': dict({ + 'day': 0.03, + 'night': 0.05, + }), + 'object_time_overlap': dict({ + 'day': 0.1, + 'night': 0.6, + }), + }), + 'enable_audio': False, + 'pir_settings': dict({ + 'sensitivity1': 1, + 'sensitivity2': 1, + 'sensitivity3': 1, + 'zone_mask': 6, + }), + 'sensitivity': 5, + 'zone1': dict({ + 'name': 'Zone 1', + 'state': 2, + 'vertex1': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex2': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex3': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex4': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex5': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex6': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex7': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex8': dict({ + 'x': 0.0, + 'y': 0.0, + }), + }), + 'zone2': dict({ + 'name': 'Zone 2', + 'state': 2, + 'vertex1': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex2': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex3': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex4': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex5': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex6': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex7': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex8': dict({ + 'x': 0.0, + 'y': 0.0, + }), + }), + 'zone3': dict({ + 'name': 'Zone 3', + 'state': 2, + 'vertex1': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex2': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex3': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex4': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex5': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex6': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex7': dict({ + 'x': 0.0, + 'y': 0.0, + }), + 'vertex8': dict({ + 'x': 0.0, + 'y': 0.0, + }), + }), + }), + 'pir_motion_zones': list([ + 0, + 1, + 1, + ]), + 'pir_settings': dict({ + 'sensitivity1': 1, + 'sensitivity2': 1, + 'sensitivity3': 1, + 'zone_mask': 6, + }), + 'stream_setting': 0, + 'video_settings': dict({ + 'ae_level': 0, + 'birton': None, + 'brightness': 0, + 'contrast': 64, + 'saturation': 80, + }), + }), + 'siren_status': dict({ + 'seconds_remaining': 30, + }), + 'stolen': False, + 'subscribed': True, + 'subscribed_motions': True, + 'time_zone': 'America/New_York', + }), + ]), + }) +# --- diff --git a/tests/components/ring/test_config_flow.py b/tests/components/ring/test_config_flow.py index 3e0c354e8fa..53c7e139a51 100644 --- a/tests/components/ring/test_config_flow.py +++ b/tests/components/ring/test_config_flow.py @@ -1,13 +1,23 @@ """Test the Ring config flow.""" -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock + +import pytest +import ring_doorbell from homeassistant import config_entries from homeassistant.components.ring import DOMAIN -from homeassistant.components.ring.config_flow import InvalidAuth +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant) -> None: +async def test_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_ring_auth: Mock, +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -16,20 +26,11 @@ async def test_form(hass: HomeAssistant) -> None: assert result["type"] == "form" assert result["errors"] == {} - with patch( - "homeassistant.components.ring.config_flow.Auth", - return_value=Mock( - fetch_token=Mock(return_value={"access_token": "mock-token"}) - ), - ), patch( - "homeassistant.components.ring.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "hello@home-assistant.io", "password": "test-password"}, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "hello@home-assistant.io", "password": "test-password"}, + ) + await hass.async_block_till_done() assert result2["type"] == "create_entry" assert result2["title"] == "hello@home-assistant.io" @@ -40,20 +41,181 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("error_type", "errors_msg"), + [ + (ring_doorbell.AuthenticationError, "invalid_auth"), + (Exception, "unknown"), + ], + ids=["invalid-auth", "unknown-error"], +) +async def test_form_error( + hass: HomeAssistant, mock_ring_auth: Mock, error_type, errors_msg +) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - - with patch( - "homeassistant.components.ring.config_flow.Auth.fetch_token", - side_effect=InvalidAuth, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "hello@home-assistant.io", "password": "test-password"}, - ) + mock_ring_auth.fetch_token.side_effect = error_type + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "hello@home-assistant.io", "password": "test-password"}, + ) assert result2["type"] == "form" - assert result2["errors"] == {"base": "invalid_auth"} + assert result2["errors"] == {"base": errors_msg} + + +async def test_form_2fa( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_ring_auth: Mock, +) -> None: + """Test form flow for 2fa.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + mock_ring_auth.fetch_token.side_effect = ring_doorbell.Requires2FAError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "foo@bar.com", + CONF_PASSWORD: "fake-password", + }, + ) + await hass.async_block_till_done() + mock_ring_auth.fetch_token.assert_called_once_with( + "foo@bar.com", "fake-password", None + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "2fa" + mock_ring_auth.fetch_token.reset_mock(side_effect=True) + mock_ring_auth.fetch_token.return_value = "new-foobar" + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={"2fa": "123456"}, + ) + + mock_ring_auth.fetch_token.assert_called_once_with( + "foo@bar.com", "fake-password", "123456" + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "foo@bar.com" + assert result3["data"] == { + "username": "foo@bar.com", + "token": "new-foobar", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_ring_auth: Mock, +) -> None: + """Test reauth flow.""" + mock_added_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + + mock_ring_auth.fetch_token.side_effect = ring_doorbell.Requires2FAError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "other_fake_password", + }, + ) + + mock_ring_auth.fetch_token.assert_called_once_with( + "foo@bar.com", "other_fake_password", None + ) + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "2fa" + mock_ring_auth.fetch_token.reset_mock(side_effect=True) + mock_ring_auth.fetch_token.return_value = "new-foobar" + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={"2fa": "123456"}, + ) + + mock_ring_auth.fetch_token.assert_called_once_with( + "foo@bar.com", "other_fake_password", "123456" + ) + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert mock_added_config_entry.data == { + "username": "foo@bar.com", + "token": "new-foobar", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("error_type", "errors_msg"), + [ + (ring_doorbell.AuthenticationError, "invalid_auth"), + (Exception, "unknown"), + ], + ids=["invalid-auth", "unknown-error"], +) +async def test_reauth_error( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_ring_auth: Mock, + error_type, + errors_msg, +) -> None: + """Test reauth flow.""" + mock_added_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + + mock_ring_auth.fetch_token.side_effect = error_type + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "error_fake_password", + }, + ) + await hass.async_block_till_done() + + mock_ring_auth.fetch_token.assert_called_once_with( + "foo@bar.com", "error_fake_password", None + ) + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": errors_msg} + + # Now test reauth can go on to succeed + mock_ring_auth.fetch_token.reset_mock(side_effect=True) + mock_ring_auth.fetch_token.return_value = "new-foobar" + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={ + CONF_PASSWORD: "other_fake_password", + }, + ) + + mock_ring_auth.fetch_token.assert_called_once_with( + "foo@bar.com", "other_fake_password", None + ) + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert mock_added_config_entry.data == { + "username": "foo@bar.com", + "token": "new-foobar", + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/ring/test_diagnostics.py b/tests/components/ring/test_diagnostics.py new file mode 100644 index 00000000000..269446c3ad5 --- /dev/null +++ b/tests/components/ring/test_diagnostics.py @@ -0,0 +1,24 @@ +"""Test Ring diagnostics.""" + +import requests_mock +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + requests_mock: requests_mock.Mocker, + snapshot: SnapshotAssertion, +) -> None: + """Test Ring diagnostics.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + diag = await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + assert diag == snapshot diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py index 7e3f5344f73..6ad79623a12 100644 --- a/tests/components/ring/test_init.py +++ b/tests/components/ring/test_init.py @@ -1,12 +1,20 @@ """The tests for the Ring component.""" +from datetime import timedelta +from unittest.mock import patch + +import pytest import requests_mock +from ring_doorbell import AuthenticationError, RingError, RingTimeout import homeassistant.components.ring as ring +from homeassistant.components.ring import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util -from tests.common import load_fixture +from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture async def test_setup(hass: HomeAssistant, requests_mock: requests_mock.Mocker) -> None: @@ -32,3 +40,152 @@ async def test_setup(hass: HomeAssistant, requests_mock: requests_mock.Mocker) - "https://api.ring.com/clients_api/doorbots/987652/health", text=load_fixture("doorboot_health_attrs.json", "ring"), ) + + +async def test_auth_failed_on_setup( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + mock_config_entry: MockConfigEntry, +) -> None: + """Test auth failure on setup entry.""" + mock_config_entry.add_to_hass(hass) + with patch( + "ring_doorbell.Ring.update_data", + side_effect=AuthenticationError, + ): + assert not any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_auth_failure_on_global_update( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + mock_config_entry: MockConfigEntry, + caplog, +) -> None: + """Test authentication failure on global data update.""" + 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 not any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + with patch( + "ring_doorbell.Ring.update_devices", + side_effect=AuthenticationError, + ): + async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) + await hass.async_block_till_done() + + assert "Ring access token is no longer valid, need to re-authenticate" in [ + record.message for record in caplog.records if record.levelname == "WARNING" + ] + + assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + + +async def test_auth_failure_on_device_update( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + mock_config_entry: MockConfigEntry, + caplog, +) -> None: + """Test authentication failure on global data update.""" + 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 not any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + with patch( + "ring_doorbell.RingDoorBell.history", + side_effect=AuthenticationError, + ): + async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) + await hass.async_block_till_done() + + assert "Ring access token is no longer valid, need to re-authenticate" in [ + record.message for record in caplog.records if record.levelname == "WARNING" + ] + + assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + + +@pytest.mark.parametrize( + ("error_type", "log_msg"), + [ + ( + RingTimeout, + "Time out fetching Ring device data", + ), + ( + RingError, + "Error fetching Ring device data: ", + ), + ], + ids=["timeout-error", "other-error"], +) +async def test_error_on_global_update( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + mock_config_entry: MockConfigEntry, + caplog, + error_type, + log_msg, +) -> None: + """Test error on global data update.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with patch( + "ring_doorbell.Ring.update_devices", + side_effect=error_type, + ): + async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) + await hass.async_block_till_done() + + assert log_msg in [ + record.message for record in caplog.records if record.levelname == "WARNING" + ] + + assert mock_config_entry.entry_id in hass.data[DOMAIN] + + +@pytest.mark.parametrize( + ("error_type", "log_msg"), + [ + ( + RingTimeout, + "Time out fetching Ring history data for device aacdef123", + ), + ( + RingError, + "Error fetching Ring history data for device aacdef123: ", + ), + ], + ids=["timeout-error", "other-error"], +) +async def test_error_on_device_update( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + mock_config_entry: MockConfigEntry, + caplog, + error_type, + log_msg, +) -> None: + """Test auth failure on data update.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with patch( + "ring_doorbell.RingDoorBell.history", + side_effect=error_type, + ): + async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) + await hass.async_block_till_done() + + assert log_msg in [ + record.message for record in caplog.records if record.levelname == "WARNING" + ] + assert mock_config_entry.entry_id in hass.data[DOMAIN] diff --git a/tests/components/risco/conftest.py b/tests/components/risco/conftest.py index cb3b3dd929e..a8a764cd502 100644 --- a/tests/components/risco/conftest.py +++ b/tests/components/risco/conftest.py @@ -140,7 +140,7 @@ async def setup_risco_cloud(hass, cloud_config_entry, events): "homeassistant.components.risco.RiscoCloud.site_name", new_callable=PropertyMock(return_value=TEST_SITE_NAME), ), patch( - "homeassistant.components.risco.RiscoCloud.close" + "homeassistant.components.risco.RiscoCloud.close", ), patch( "homeassistant.components.risco.RiscoCloud.get_events", return_value=events, @@ -171,6 +171,16 @@ def connect_with_error(exception): yield +@pytest.fixture +def connect_with_single_error(exception): + """Fixture to simulate error on connect.""" + with patch( + "homeassistant.components.risco.RiscoLocal.connect", + side_effect=[exception, None], + ): + yield + + @pytest.fixture async def setup_risco_local(hass, local_config_entry): """Set up a local Risco integration for testing.""" @@ -181,7 +191,7 @@ async def setup_risco_local(hass, local_config_entry): "homeassistant.components.risco.RiscoLocal.id", new_callable=PropertyMock(return_value=TEST_SITE_UUID), ), patch( - "homeassistant.components.risco.RiscoLocal.disconnect" + "homeassistant.components.risco.RiscoLocal.disconnect", ): await hass.config_entries.async_setup(local_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/risco/test_config_flow.py b/tests/components/risco/test_config_flow.py index 5a9b60ed130..8207ad819b7 100644 --- a/tests/components/risco/test_config_flow.py +++ b/tests/components/risco/test_config_flow.py @@ -9,7 +9,7 @@ from homeassistant.components.risco.config_flow import ( CannotConnectError, UnauthorizedError, ) -from homeassistant.components.risco.const import DOMAIN +from homeassistant.components.risco.const import CONF_COMMUNICATION_DELAY, DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -162,7 +162,7 @@ async def test_form_reauth(hass: HomeAssistant, cloud_config_entry) -> None: "homeassistant.components.risco.config_flow.RiscoCloud.site_name", new_callable=PropertyMock(return_value=TEST_SITE_NAME), ), patch( - "homeassistant.components.risco.config_flow.RiscoCloud.close" + "homeassistant.components.risco.config_flow.RiscoCloud.close", ), patch( "homeassistant.components.risco.async_setup_entry", return_value=True, @@ -198,7 +198,7 @@ async def test_form_reauth_with_new_username( "homeassistant.components.risco.config_flow.RiscoCloud.site_name", new_callable=PropertyMock(return_value=TEST_SITE_NAME), ), patch( - "homeassistant.components.risco.config_flow.RiscoCloud.close" + "homeassistant.components.risco.config_flow.RiscoCloud.close", ), patch( "homeassistant.components.risco.async_setup_entry", return_value=True, @@ -246,7 +246,10 @@ async def test_local_form(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - expected_data = {**TEST_LOCAL_DATA, **{"type": "local"}} + expected_data = { + **TEST_LOCAL_DATA, + **{"type": "local", CONF_COMMUNICATION_DELAY: 0}, + } assert result3["type"] == FlowResultType.CREATE_ENTRY assert result3["title"] == TEST_SITE_NAME assert result3["data"] == expected_data @@ -304,7 +307,7 @@ async def test_form_local_already_exists(hass: HomeAssistant) -> None: "homeassistant.components.risco.config_flow.RiscoLocal.id", new_callable=PropertyMock(return_value=TEST_SITE_NAME), ), patch( - "homeassistant.components.risco.config_flow.RiscoLocal.disconnect" + "homeassistant.components.risco.config_flow.RiscoLocal.disconnect", ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], TEST_LOCAL_DATA diff --git a/tests/components/risco/test_init.py b/tests/components/risco/test_init.py new file mode 100644 index 00000000000..a1a9e3bd6a7 --- /dev/null +++ b/tests/components/risco/test_init.py @@ -0,0 +1,21 @@ +"""Tests for the Risco initialization.""" +import pytest + +from homeassistant.components.risco import CannotConnectError +from homeassistant.components.risco.const import CONF_COMMUNICATION_DELAY +from homeassistant.core import HomeAssistant + + +@pytest.mark.parametrize("exception", [CannotConnectError]) +async def test_single_error_on_connect( + hass: HomeAssistant, connect_with_single_error, local_config_entry +) -> None: + """Test single error on connect to validate communication delay update from 0 (default) to 1.""" + expected_data = { + **local_config_entry.data, + **{"type": "local", CONF_COMMUNICATION_DELAY: 1}, + } + + await hass.config_entries.async_setup(local_config_entry.entry_id) + await hass.async_block_till_done() + assert local_config_entry.data == expected_data diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 3435bd58cb3..711ae203e0f 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -12,7 +12,16 @@ from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .mock_data import BASE_URL, HOME_DATA, NETWORK_INFO, PROP, USER_DATA, USER_EMAIL +from .mock_data import ( + BASE_URL, + HOME_DATA, + MAP_DATA, + MULTI_MAP_LIST, + NETWORK_INFO, + PROP, + USER_DATA, + USER_EMAIL, +) from tests.common import MockConfigEntry @@ -33,6 +42,12 @@ def bypass_api_fixture() -> None: ), patch( "homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop", return_value=PROP, + ), patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClient.get_multi_maps_list", + return_value=MULTI_MAP_LIST, + ), patch( + "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + return_value=MAP_DATA, ), patch( "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" ), patch( @@ -40,9 +55,12 @@ def bypass_api_fixture() -> None: ), patch( "homeassistant.components.roborock.coordinator.RoborockLocalClient._wait_response" ), patch( - "roborock.api.AttributeCache.async_value" + "roborock.api.AttributeCache.async_value", ), patch( - "roborock.api.AttributeCache.value" + "roborock.api.AttributeCache.value", + ), patch( + "homeassistant.components.roborock.image.MAP_SLEEP", + 0, ): yield diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 87ed02bc3ec..8935a77f142 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -1,17 +1,22 @@ """Mock data for Roborock tests.""" from __future__ import annotations +from PIL import Image from roborock.containers import ( CleanRecord, CleanSummary, Consumable, DnDTimer, HomeData, + MultiMapsList, NetworkInfo, S7Status, UserData, ) from roborock.roborock_typing import DeviceProp +from vacuum_map_parser_base.config.image_config import ImageConfig +from vacuum_map_parser_base.map_data import ImageData +from vacuum_map_parser_roborock.map_data_parser import MapData from homeassistant.components.roborock import CONF_BASE_URL, CONF_USER_DATA from homeassistant.const import CONF_USERNAME @@ -418,3 +423,32 @@ PROP = DeviceProp( NETWORK_INFO = NetworkInfo( ip="123.232.12.1", ssid="wifi", mac="ac:cc:cc:cc:cc", bssid="bssid", rssi=90 ) + +MULTI_MAP_LIST = MultiMapsList.from_dict( + { + "maxMultiMap": 4, + "maxBakMap": 1, + "multiMapCount": 2, + "mapInfo": [ + { + "mapFlag": 0, + "addTime": 1686235489, + "length": 8, + "name": "Upstairs", + "bakMaps": [{"addTime": 1673304288}], + }, + { + "mapFlag": 1, + "addTime": 1697579901, + "length": 10, + "name": "Downstairs", + "bakMaps": [{"addTime": 1695521431}], + }, + ], + } +) + +MAP_DATA = MapData(0, 0) +MAP_DATA.image = ImageData( + 100, 10, 10, 10, 10, ImageConfig(), Image.new("RGB", (1, 1)), lambda p: p +) diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index d8e5f7d4cb2..6d851e41bce 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -260,7 +260,17 @@ 'dockType': 3, 'dustCollectionStatus': 0, 'errorCode': 0, + 'errorCodeName': 'none', 'fanPower': 102, + 'fanPowerName': 'balanced', + 'fanPowerOptions': list([ + 'off', + 'quiet', + 'balanced', + 'turbo', + 'max', + 'custom', + ]), 'homeSecEnablePassword': 0, 'homeSecStatus': 0, 'inCleaning': 0, @@ -274,10 +284,12 @@ 'mapStatus': 3, 'mopForbiddenEnable': 1, 'mopMode': 300, + 'mopModeName': 'standard', 'msgSeq': 458, 'msgVer': 2, 'squareMeterCleanArea': 21.0, 'state': 8, + 'stateName': 'charging', 'switchMapMode': 0, 'unsaveMapFlag': 0, 'unsaveMapReason': 0, @@ -285,6 +297,7 @@ 'washReady': 0, 'waterBoxCarriageStatus': 1, 'waterBoxMode': 203, + 'waterBoxModeName': 'intense', 'waterBoxStatus': 1, 'waterShortageStatus': 0, }), @@ -521,7 +534,17 @@ 'dockType': 3, 'dustCollectionStatus': 0, 'errorCode': 0, + 'errorCodeName': 'none', 'fanPower': 102, + 'fanPowerName': 'balanced', + 'fanPowerOptions': list([ + 'off', + 'quiet', + 'balanced', + 'turbo', + 'max', + 'custom', + ]), 'homeSecEnablePassword': 0, 'homeSecStatus': 0, 'inCleaning': 0, @@ -535,10 +558,12 @@ 'mapStatus': 3, 'mopForbiddenEnable': 1, 'mopMode': 300, + 'mopModeName': 'standard', 'msgSeq': 458, 'msgVer': 2, 'squareMeterCleanArea': 21.0, 'state': 8, + 'stateName': 'charging', 'switchMapMode': 0, 'unsaveMapFlag': 0, 'unsaveMapReason': 0, @@ -546,6 +571,7 @@ 'washReady': 0, 'waterBoxCarriageStatus': 1, 'waterBoxMode': 203, + 'waterBoxModeName': 'intense', 'waterBoxStatus': 1, 'waterShortageStatus': 0, }), diff --git a/tests/components/roborock/test_button.py b/tests/components/roborock/test_button.py new file mode 100644 index 00000000000..3948e0c161a --- /dev/null +++ b/tests/components/roborock/test_button.py @@ -0,0 +1,42 @@ +"""Test Roborock Button platform.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.button import SERVICE_PRESS +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("entity_id"), + [ + ("button.roborock_s7_maxv_reset_sensor_consumable"), + ("button.roborock_s7_maxv_reset_air_filter_consumable"), + ("button.roborock_s7_maxv_reset_side_brush_consumable"), + "button.roborock_s7_maxv_reset_main_brush_consumable", + ], +) +@pytest.mark.freeze_time("2023-10-30 08:50:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_update_success( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + entity_id: str, +) -> None: + """Test pressing the button entities.""" + # Ensure that the entity exist, as these test can pass even if there is no entity. + assert hass.states.get(entity_id).state == "unknown" + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" + ) as mock_send_message: + await hass.services.async_call( + "button", + SERVICE_PRESS, + blocking=True, + target={"entity_id": entity_id}, + ) + assert mock_send_message.assert_called_once + assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index bbaa8935461..e2454b3ad57 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -1,4 +1,5 @@ """Test Roborock config flow.""" +from copy import deepcopy from unittest.mock import patch import pytest @@ -12,9 +13,11 @@ from roborock.exceptions import ( from homeassistant import config_entries from homeassistant.components.roborock.const import CONF_ENTRY_CODE, DOMAIN +from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from ...common import MockConfigEntry from .mock_data import MOCK_CONFIG, USER_DATA, USER_EMAIL @@ -35,7 +38,7 @@ async def test_config_flow_success( "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"username": USER_EMAIL} + result["flow_id"], {CONF_USERNAME: USER_EMAIL} ) assert result["type"] == FlowResultType.FORM @@ -89,7 +92,7 @@ async def test_config_flow_failures_request_code( side_effect=request_code_side_effect, ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"username": USER_EMAIL} + result["flow_id"], {CONF_USERNAME: USER_EMAIL} ) assert result["type"] == FlowResultType.FORM assert result["errors"] == request_code_errors @@ -98,7 +101,7 @@ async def test_config_flow_failures_request_code( "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"username": USER_EMAIL} + result["flow_id"], {CONF_USERNAME: USER_EMAIL} ) assert result["type"] == FlowResultType.FORM @@ -149,7 +152,7 @@ async def test_config_flow_failures_code_login( "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"username": USER_EMAIL} + result["flow_id"], {CONF_USERNAME: USER_EMAIL} ) assert result["type"] == FlowResultType.FORM @@ -178,3 +181,39 @@ async def test_config_flow_failures_code_login( assert result["data"] == MOCK_CONFIG assert result["result"] assert len(mock_setup.mock_calls) == 1 + + +async def test_reauth_flow( + hass: HomeAssistant, bypass_api_fixture, mock_roborock_entry: MockConfigEntry +) -> None: + """Test reauth flow.""" + # Start reauth + result = mock_roborock_entry.async_start_reauth(hass) + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + + # Request a new code + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + # Enter a new code + assert result["step_id"] == "code" + assert result["type"] == FlowResultType.FORM + new_user_data = deepcopy(USER_DATA) + new_user_data.rriot.s = "new_password_hash" + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + return_value=new_user_data, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_roborock_entry.data["user_data"]["rriot"]["s"] == "new_password_hash" diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py new file mode 100644 index 00000000000..80d4bd37337 --- /dev/null +++ b/tests/components/roborock/test_image.py @@ -0,0 +1,75 @@ +"""Test Roborock Image platform.""" +import copy +from datetime import timedelta +from http import HTTPStatus +from unittest.mock import patch + +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.roborock.mock_data import MAP_DATA, PROP +from tests.typing import ClientSessionGenerator + + +async def test_floorplan_image( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test floor plan map image is correctly set up.""" + # Setup calls the image parsing the first time and caches it. + assert len(hass.states.async_all("image")) == 4 + + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + # call a second time -should return cached data + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body is not None + # Call a third time - this time forcing it to update + now = dt_util.utcnow() + timedelta(seconds=91) + async_fire_time_changed(hass, now) + # Copy the device prop so we don't override it + prop = copy.deepcopy(PROP) + prop.status.in_cleaning = 1 + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop", + return_value=prop, + ), patch( + "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now + ): + await hass.async_block_till_done() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body is not None + + +async def test_floorplan_image_failed_parse( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test that we correctly handle getting None from the image parser.""" + client = await hass_client() + map_data = copy.deepcopy(MAP_DATA) + map_data.image = None + now = dt_util.utcnow() + timedelta(seconds=91) + async_fire_time_changed(hass, now) + # Copy the device prop so we don't override it + prop = copy.deepcopy(PROP) + prop.status.in_cleaning = 1 + # Update image, but get none for parse image. + with patch( + "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + return_value=map_data, + ), patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop", + return_value=prop, + ), patch( + "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now + ): + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert not resp.ok diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index a5ad24b431c..5d1afaf8f84 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -1,10 +1,11 @@ """Test for Roborock init.""" from unittest.mock import patch +from roborock import RoborockException, RoborockInvalidCredentials + from homeassistant.components.roborock.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -33,8 +34,89 @@ async def test_config_entry_not_ready( with patch( "homeassistant.components.roborock.RoborockApiClient.get_home_data", ), patch( - "homeassistant.components.roborock.RoborockDataUpdateCoordinator._async_update_data", - side_effect=UpdateFailed(), + "homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop", + side_effect=RoborockException(), ): await async_setup_component(hass, DOMAIN, {}) assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_not_ready_home_data( + hass: HomeAssistant, mock_roborock_entry: MockConfigEntry +) -> None: + """Test that when we fail to get home data, entry retries.""" + with patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data", + side_effect=RoborockException(), + ), patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop", + side_effect=RoborockException(), + ): + await async_setup_component(hass, DOMAIN, {}) + assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_get_networking_fails( + hass: HomeAssistant, mock_roborock_entry: MockConfigEntry, bypass_api_fixture +) -> None: + """Test that when networking fails, we attempt to retry.""" + with patch( + "homeassistant.components.roborock.RoborockMqttClient.get_networking", + side_effect=RoborockException(), + ): + await async_setup_component(hass, DOMAIN, {}) + assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_get_networking_fails_none( + hass: HomeAssistant, mock_roborock_entry: MockConfigEntry, bypass_api_fixture +) -> None: + """Test that when networking returns None, we attempt to retry.""" + with patch( + "homeassistant.components.roborock.RoborockMqttClient.get_networking", + return_value=None, + ): + await async_setup_component(hass, DOMAIN, {}) + assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_cloud_client_fails_props( + hass: HomeAssistant, mock_roborock_entry: MockConfigEntry, bypass_api_fixture +) -> None: + """Test that if networking succeeds, but we can't communicate with the vacuum, we can't get props, fail.""" + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.ping", + side_effect=RoborockException(), + ), patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClient.get_prop", + side_effect=RoborockException(), + ): + await async_setup_component(hass, DOMAIN, {}) + assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_local_client_fails_props( + hass: HomeAssistant, mock_roborock_entry: MockConfigEntry, bypass_api_fixture +) -> None: + """Test that if networking succeeds, but we can't communicate locally with the vacuum, we can't get props, fail.""" + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop", + side_effect=RoborockException(), + ): + await async_setup_component(hass, DOMAIN, {}) + assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_reauth_started( + hass: HomeAssistant, bypass_api_fixture, mock_roborock_entry: MockConfigEntry +) -> None: + """Test reauth flow started.""" + with patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data", + side_effect=RoborockInvalidCredentials(), + ): + await async_setup_component(hass, DOMAIN, {}) + assert mock_roborock_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/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index 35fcc9478cd..4966c8fa3be 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -1,14 +1,20 @@ """Test Roborock Sensors.""" +from unittest.mock import patch +from roborock import DeviceData, HomeDataDevice +from roborock.cloud_api import RoborockMqttClient from roborock.const import ( FILTER_REPLACE_TIME, MAIN_BRUSH_REPLACE_TIME, SENSOR_DIRTY_REPLACE_TIME, SIDE_BRUSH_REPLACE_TIME, ) +from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol from homeassistant.core import HomeAssistant +from .mock_data import CONSUMABLE, STATUS, USER_DATA + from tests.common import MockConfigEntry @@ -47,3 +53,41 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non hass.states.get("sensor.roborock_s7_maxv_last_clean_end").state == "2023-01-01T03:43:58+00:00" ) + + +async def test_listener_update( + hass: HomeAssistant, setup_entry: MockConfigEntry +) -> None: + """Test that when we receive a mqtt topic, we successfully update the entity.""" + assert hass.states.get("sensor.roborock_s7_maxv_status").state == "charging" + # Listeners are global based on uuid - so this is okay + client = RoborockMqttClient( + USER_DATA, DeviceData(device=HomeDataDevice("abc123", "", "", "", ""), model="") + ) + # Test Status + with patch("roborock.api.AttributeCache.value", STATUS.as_dict()): + # Symbolizes a mqtt message coming in + client.on_message_received( + [ + RoborockMessage( + protocol=RoborockMessageProtocol.GENERAL_REQUEST, + payload=b'{"t": 1699464794, "dps": {"121": 5}}', + ) + ] + ) + # Test consumable + assert hass.states.get("sensor.roborock_s7_maxv_filter_time_left").state == str( + FILTER_REPLACE_TIME - 74382 + ) + with patch("roborock.api.AttributeCache.value", CONSUMABLE.as_dict()): + client.on_message_received( + [ + RoborockMessage( + protocol=RoborockMessageProtocol.GENERAL_REQUEST, + payload=b'{"t": 1699464794, "dps": {"127": 743}}', + ) + ] + ) + assert hass.states.get("sensor.roborock_s7_maxv_filter_time_left").state == str( + FILTER_REPLACE_TIME - 743 + ) diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index 5e8ab9311aa..874697bf777 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -45,9 +45,9 @@ async def silent_ssdp_scanner(hass): ), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch( "homeassistant.components.ssdp.Scanner.async_scan" ), patch( - "homeassistant.components.ssdp.Server._async_start_upnp_servers" + "homeassistant.components.ssdp.Server._async_start_upnp_servers", ), patch( - "homeassistant.components.ssdp.Server._async_stop_upnp_servers" + "homeassistant.components.ssdp.Server._async_stop_upnp_servers", ): yield diff --git a/tests/components/schlage/conftest.py b/tests/components/schlage/conftest.py index 7b610a6b4da..5f9676b7d09 100644 --- a/tests/components/schlage/conftest.py +++ b/tests/components/schlage/conftest.py @@ -54,14 +54,14 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_schlage(): +def mock_schlage() -> Mock: """Mock pyschlage.Schlage.""" with patch("pyschlage.Schlage", autospec=True) as mock_schlage: yield mock_schlage.return_value @pytest.fixture -def mock_pyschlage_auth(): +def mock_pyschlage_auth() -> Mock: """Mock pyschlage.Auth.""" with patch("pyschlage.Auth", autospec=True) as mock_auth: mock_auth.return_value.user_id = "abc123" @@ -69,7 +69,7 @@ def mock_pyschlage_auth(): @pytest.fixture -def mock_lock(): +def mock_lock() -> Mock: """Mock Lock fixture.""" mock_lock = create_autospec(Lock) mock_lock.configure_mock( diff --git a/tests/components/schlage/test_config_flow.py b/tests/components/schlage/test_config_flow.py index b256e8950ed..14121f5d9ca 100644 --- a/tests/components/schlage/test_config_flow.py +++ b/tests/components/schlage/test_config_flow.py @@ -9,6 +9,8 @@ from homeassistant.components.schlage.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -78,3 +80,94 @@ async def test_form_unknown(hass: HomeAssistant, mock_pyschlage_auth: Mock) -> N assert result2["type"] == FlowResultType.FORM assert result2["errors"] == {"base": "unknown"} + + +async def test_reauth( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_pyschlage_auth: Mock, +) -> None: + """Test reauth flow.""" + mock_added_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"password": "new-password"}, + ) + await hass.async_block_till_done() + + mock_pyschlage_auth.authenticate.assert_called_once_with() + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert mock_added_config_entry.data == { + "username": "asdf@asdf.com", + "password": "new-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_invalid_auth( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_pyschlage_auth: Mock, +) -> None: + """Test reauth flow.""" + mock_added_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + + mock_pyschlage_auth.authenticate.reset_mock() + mock_pyschlage_auth.authenticate.side_effect = NotAuthorizedError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"password": "new-password"}, + ) + await hass.async_block_till_done() + + mock_pyschlage_auth.authenticate.assert_called_once_with() + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_reauth_wrong_account( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_pyschlage_auth: Mock, +) -> None: + """Test reauth flow.""" + mock_pyschlage_auth.user_id = "bad-user-id" + mock_added_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"password": "new-password"}, + ) + await hass.async_block_till_done() + + mock_pyschlage_auth.authenticate.assert_called_once_with() + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "wrong_account" + assert mock_added_config_entry.data == { + "username": "asdf@asdf.com", + "password": "hunter2", + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/schlage/test_diagnostics.py b/tests/components/schlage/test_diagnostics.py new file mode 100644 index 00000000000..15b2316bf38 --- /dev/null +++ b/tests/components/schlage/test_diagnostics.py @@ -0,0 +1,23 @@ +"""Test Schlage diagnostics.""" + +from unittest.mock import Mock + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_added_config_entry: MockConfigEntry, + mock_lock: Mock, +) -> None: + """Test Schlage diagnostics.""" + mock_lock.get_diagnostics.return_value = {"foo": "bar"} + diag = await get_diagnostics_for_config_entry( + hass, hass_client, mock_added_config_entry + ) + assert diag == {"locks": [{"foo": "bar"}]} diff --git a/tests/components/schlage/test_init.py b/tests/components/schlage/test_init.py index 0811d87ec80..0fe7af1982b 100644 --- a/tests/components/schlage/test_init.py +++ b/tests/components/schlage/test_init.py @@ -3,7 +3,7 @@ from unittest.mock import Mock, patch from pycognito.exceptions import WarrantException -from pyschlage.exceptions import Error +from pyschlage.exceptions import Error, NotAuthorizedError from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -43,6 +43,41 @@ async def test_update_data_fails( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_update_data_auth_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pyschlage_auth: Mock, + mock_schlage: Mock, +) -> None: + """Test that we properly handle API errors.""" + mock_schlage.locks.side_effect = NotAuthorizedError + 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_schlage.locks.call_count == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_update_data_get_logs_auth_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pyschlage_auth: Mock, + mock_schlage: Mock, + mock_lock: Mock, +) -> None: + """Test that we properly handle API errors.""" + mock_schlage.locks.return_value = [mock_lock] + mock_lock.logs.reset_mock() + mock_lock.logs.side_effect = NotAuthorizedError + 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_schlage.locks.call_count == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/sensibo/test_button.py b/tests/components/sensibo/test_button.py index da6a68af2d1..2277c84d187 100644 --- a/tests/components/sensibo/test_button.py +++ b/tests/components/sensibo/test_button.py @@ -100,7 +100,7 @@ async def test_button_failure( "homeassistant.components.sensibo.util.SensiboClient.async_reset_filter", return_value={"status": "failure"}, ), pytest.raises( - HomeAssistantError + HomeAssistantError, ): await hass.services.async_call( BUTTON_DOMAIN, diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 530034720f2..9cf0a8972a9 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -742,7 +742,7 @@ async def test_climate_set_timer( "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", return_value={"status": "failure"}, ), pytest.raises( - MultipleInvalid + MultipleInvalid, ): await hass.services.async_call( DOMAIN, @@ -761,7 +761,7 @@ async def test_climate_set_timer( "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", return_value={"status": "failure"}, ), pytest.raises( - HomeAssistantError + HomeAssistantError, ): await hass.services.async_call( DOMAIN, @@ -845,7 +845,7 @@ async def test_climate_pure_boost( ), patch( "homeassistant.components.sensibo.util.SensiboClient.async_set_pureboost", ), pytest.raises( - MultipleInvalid + MultipleInvalid, ): await hass.services.async_call( DOMAIN, @@ -947,7 +947,7 @@ async def test_climate_climate_react( ), patch( "homeassistant.components.sensibo.util.SensiboClient.async_set_climate_react", ), pytest.raises( - MultipleInvalid + MultipleInvalid, ): await hass.services.async_call( DOMAIN, @@ -1254,7 +1254,7 @@ async def test_climate_full_ac_state( ), patch( "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_states", ), pytest.raises( - MultipleInvalid + MultipleInvalid, ): await hass.services.async_call( DOMAIN, diff --git a/tests/components/sensibo/test_select.py b/tests/components/sensibo/test_select.py index 7d8e3731415..41a67dfbe79 100644 --- a/tests/components/sensibo/test_select.py +++ b/tests/components/sensibo/test_select.py @@ -90,7 +90,7 @@ async def test_select_set_option( "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", return_value={"result": {"status": "failed"}}, ), pytest.raises( - HomeAssistantError + HomeAssistantError, ): await hass.services.async_call( SELECT_DOMAIN, @@ -132,7 +132,7 @@ async def test_select_set_option( "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", return_value={"result": {"status": "Failed", "failureReason": "No connection"}}, ), pytest.raises( - HomeAssistantError + HomeAssistantError, ): await hass.services.async_call( SELECT_DOMAIN, diff --git a/tests/components/sensibo/test_switch.py b/tests/components/sensibo/test_switch.py index c6d47ceed66..e319be85c73 100644 --- a/tests/components/sensibo/test_switch.py +++ b/tests/components/sensibo/test_switch.py @@ -196,7 +196,7 @@ async def test_switch_command_failure( "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", return_value={"status": "failure"}, ), pytest.raises( - HomeAssistantError + HomeAssistantError, ): await hass.services.async_call( SWITCH_DOMAIN, @@ -214,7 +214,7 @@ async def test_switch_command_failure( "homeassistant.components.sensibo.util.SensiboClient.async_del_timer", return_value={"status": "failure"}, ), pytest.raises( - HomeAssistantError + HomeAssistantError, ): await hass.services.async_call( SWITCH_DOMAIN, diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 464118ac99b..0384e9255a3 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -7,6 +7,7 @@ from datetime import timedelta from typing import Any from unittest.mock import Mock +from aioshelly.const import MODEL_25 from freezegun.api import FrozenDateTimeFactory import pytest @@ -30,7 +31,7 @@ MOCK_MAC = "123456789ABC" async def init_integration( hass: HomeAssistant, gen: int, - model="SHSW-25", + model=MODEL_25, sleep_period=0, options: dict[str, Any] | None = None, skip_setup: bool = False, diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 438ca9b5ace..6eb74e26dcb 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -4,6 +4,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock, PropertyMock, patch from aioshelly.block_device import BlockDevice, BlockUpdateType +from aioshelly.const import MODEL_1, MODEL_25, MODEL_PLUS_2PM from aioshelly.rpc_device import RpcDevice, RpcUpdateType import pytest @@ -22,7 +23,7 @@ MOCK_SETTINGS = { "device": { "mac": MOCK_MAC, "hostname": "test-host", - "type": "SHSW-25", + "type": MODEL_25, "num_outputs": 2, }, "coiot": {"update_period": 15}, @@ -148,6 +149,11 @@ MOCK_CONFIG = { "light:0": {"name": "test light_0"}, "switch:0": {"name": "test switch_0"}, "cover:0": {"name": "test cover_0"}, + "thermostat:0": { + "id": 0, + "enable": True, + "type": "heating", + }, "sys": { "ui_data": {}, "device": {"name": "Test name"}, @@ -166,7 +172,7 @@ MOCK_SHELLY_RPC = { "name": "Test Gen2", "id": "shellyplus2pm-123456789abc", "mac": MOCK_MAC, - "model": "SNSW-002P16EU", + "model": MODEL_PLUS_2PM, "gen": 2, "fw_id": "20220830-130540/0.11.0-gfa1bc37", "ver": "0.11.0", @@ -174,6 +180,7 @@ MOCK_SHELLY_RPC = { "auth_en": False, "auth_domain": None, "profile": "cover", + "relay_in_thermostat": True, } MOCK_STATUS_COAP = { @@ -207,6 +214,13 @@ MOCK_STATUS_RPC = { "em1:1": {"act_power": 123.3}, "em1data:0": {"total_act_energy": 123456.4}, "em1data:1": {"total_act_energy": 987654.3}, + "thermostat:0": { + "id": 0, + "enable": True, + "target_C": 23, + "current_C": 12.3, + "output": True, + }, "sys": { "available_updates": { "beta": {"version": "some_beta_version"}, @@ -280,7 +294,8 @@ async def mock_block_device(): status=MOCK_STATUS_COAP, firmware_version="some fw string", initialized=True, - model="SHSW-1", + model=MODEL_1, + gen=1, ) type(device).name = PropertyMock(return_value="Test name") block_device_mock.return_value = device diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index 8905ff5c3e8..8a5e0108ad7 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -1,4 +1,5 @@ """Tests for Shelly binary sensor platform.""" +from aioshelly.const import MODEL_MOTION from freezegun.api import FrozenDateTimeFactory from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN @@ -77,9 +78,9 @@ async def test_block_rest_binary_sensor_connected_battery_devices( """Test block REST binary sensor for connected battery devices.""" entity_id = register_entity(hass, BINARY_SENSOR_DOMAIN, "test_name_cloud", "cloud") monkeypatch.setitem(mock_block_device.status, "cloud", {"connected": False}) - monkeypatch.setitem(mock_block_device.settings["device"], "type", "SHMOS-01") + monkeypatch.setitem(mock_block_device.settings["device"], "type", MODEL_MOTION) monkeypatch.setitem(mock_block_device.settings["coiot"], "update_period", 3600) - await init_integration(hass, 1, model="SHMOS-01") + await init_integration(hass, 1, model=MODEL_MOTION) assert hass.states.get(entity_id).state == STATE_OFF diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index 08ec548d3f0..fe518b8509c 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -1,10 +1,14 @@ """Tests for Shelly climate platform.""" +from copy import deepcopy from unittest.mock import AsyncMock, PropertyMock +from aioshelly.const import MODEL_VALVE from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError import pytest from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_PRESET_MODE, ATTR_TARGET_TEMP_HIGH, @@ -14,13 +18,15 @@ from homeassistant.components.climate import ( SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, SERVICE_SET_TEMPERATURE, + HVACAction, HVACMode, ) -from homeassistant.components.shelly.const import DOMAIN +from homeassistant.components.shelly.const import DOMAIN, MODEL_WALL_DISPLAY from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.issue_registry as ir from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -49,7 +55,7 @@ async def test_climate_hvac_mode( monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) monkeypatch.delattr(mock_block_device.blocks[EMETER_BLOCK_ID], "targetTemp") monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp") - await init_integration(hass, 1, sleep_period=1000, model="SHTRV-01") + await init_integration(hass, 1, sleep_period=1000, model=MODEL_VALVE) # Make device online mock_block_device.mock_update() @@ -150,7 +156,7 @@ async def test_climate_set_preset_mode( monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp") monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "mode", None) - await init_integration(hass, 1, sleep_period=1000, model="SHTRV-01") + await init_integration(hass, 1, sleep_period=1000, model=MODEL_VALVE) # Make device online mock_block_device.mock_update() @@ -502,7 +508,7 @@ async def test_device_not_calibrated( """Test to create an issue when the device is not calibrated.""" issue_registry: ir.IssueRegistry = ir.async_get(hass) - await init_integration(hass, 1, sleep_period=1000, model="SHTRV-01") + await init_integration(hass, 1, sleep_period=1000, model=MODEL_VALVE) # Make device online mock_block_device.mock_update() @@ -534,3 +540,97 @@ async def test_device_not_calibrated( assert not issue_registry.async_get_issue( domain=DOMAIN, issue_id=f"not_calibrated_{MOCK_MAC}" ) + + +async def test_rpc_climate_hvac_mode( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_rpc_device, + monkeypatch, +) -> None: + """Test climate hvac mode service.""" + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 23 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 12.3 + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING + + entry = entity_registry.async_get(ENTITY_ID) + assert entry + assert entry.unique_id == "123456789ABC-thermostat:0" + + monkeypatch.setitem(mock_rpc_device.status["thermostat:0"], "output", False) + mock_rpc_device.mock_update() + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + + monkeypatch.setitem(mock_rpc_device.status["thermostat:0"], "enable", False) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + mock_rpc_device.mock_update() + + mock_rpc_device.call_rpc.assert_called_once_with( + "Thermostat.SetConfig", {"config": {"id": 0, "enable": False}} + ) + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.OFF + + +async def test_rpc_climate_set_temperature( + hass: HomeAssistant, mock_rpc_device, monkeypatch +) -> None: + """Test climate set target temperature.""" + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_TEMPERATURE] == 23 + + # test set temperature without target temperature + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TARGET_TEMP_LOW: 20, + ATTR_TARGET_TEMP_HIGH: 30, + }, + blocking=True, + ) + mock_rpc_device.call_rpc.assert_not_called() + + monkeypatch.setitem(mock_rpc_device.status["thermostat:0"], "target_C", 28) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 28}, + blocking=True, + ) + mock_rpc_device.mock_update() + + mock_rpc_device.call_rpc.assert_called_once_with( + "Thermostat.SetConfig", {"config": {"id": 0, "target_C": 28}} + ) + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_TEMPERATURE] == 28 + + +async def test_rpc_climate_hvac_mode_cool( + hass: HomeAssistant, mock_rpc_device, monkeypatch +) -> None: + """Test climate with hvac mode cooling.""" + new_config = deepcopy(mock_rpc_device.config) + new_config["thermostat:0"]["type"] = "cooling" + monkeypatch.setattr(mock_rpc_device, "config", new_config) + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.COOL + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 073847e0308..9482080a1a3 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -5,6 +5,7 @@ from dataclasses import replace from ipaddress import ip_address from unittest.mock import AsyncMock, patch +from aioshelly.const import MODEL_1, MODEL_PLUS_2PM from aioshelly.exceptions import ( DeviceConnectionError, FirmwareUnsupported, @@ -52,8 +53,8 @@ DISCOVERY_INFO_WITH_MAC = zeroconf.ZeroconfServiceInfo( @pytest.mark.parametrize( ("gen", "model"), [ - (1, "SHSW-1"), - (2, "SNSW-002P16EU"), + (1, MODEL_1), + (2, MODEL_PLUS_2PM), ], ) async def test_form( @@ -68,7 +69,7 @@ async def test_form( with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False, "gen": gen}, + return_value={"mac": "test-mac", "type": MODEL_1, "auth": False, "gen": gen}, ), patch( "homeassistant.components.shelly.async_setup", return_value=True ) as mock_setup, patch( @@ -98,13 +99,13 @@ async def test_form( [ ( 1, - "SHSW-1", + MODEL_1, {"username": "test user", "password": "test1 password"}, "test user", ), ( 2, - "SNSW-002P16EU", + MODEL_PLUS_2PM, {"password": "test2 password"}, "admin", ), @@ -128,7 +129,7 @@ async def test_form_auth( with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": "SHSW-1", "auth": True, "gen": gen}, + return_value={"mac": "test-mac", "type": MODEL_1, "auth": True, "gen": gen}, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -306,7 +307,7 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, + return_value={"mac": "test-mac", "type": MODEL_1, "auth": False}, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -339,7 +340,7 @@ async def test_user_setup_ignored_device( with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, + return_value={"mac": "test-mac", "type": MODEL_1, "auth": False}, ), patch( "homeassistant.components.shelly.async_setup", return_value=True ) as mock_setup, patch( @@ -456,13 +457,13 @@ async def test_form_auth_errors_test_connection_gen2( [ ( 1, - "SHSW-1", - {"mac": "test-mac", "type": "SHSW-1", "auth": False, "gen": 1}, + MODEL_1, + {"mac": "test-mac", "type": MODEL_1, "auth": False, "gen": 1}, ), ( 2, - "SNSW-002P16EU", - {"mac": "test-mac", "model": "SHSW-1", "auth": False, "gen": 2}, + MODEL_PLUS_2PM, + {"mac": "test-mac", "model": MODEL_PLUS_2PM, "auth": False, "gen": 2}, ), ], ) @@ -525,7 +526,7 @@ async def test_zeroconf_sleeping_device( "homeassistant.components.shelly.config_flow.get_info", return_value={ "mac": "test-mac", - "type": "SHSW-1", + "type": MODEL_1, "auth": False, "sleep_mode": True, }, @@ -559,7 +560,7 @@ async def test_zeroconf_sleeping_device( assert result2["title"] == "Test name" assert result2["data"] == { "host": "1.1.1.1", - "model": "SHSW-1", + "model": MODEL_1, "sleep_period": 600, "gen": 1, } @@ -573,7 +574,7 @@ async def test_zeroconf_sleeping_device_error(hass: HomeAssistant) -> None: "homeassistant.components.shelly.config_flow.get_info", return_value={ "mac": "test-mac", - "type": "SHSW-1", + "type": MODEL_1, "auth": False, "sleep_mode": True, }, @@ -600,7 +601,7 @@ async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, + return_value={"mac": "test-mac", "type": MODEL_1, "auth": False}, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -627,7 +628,7 @@ async def test_zeroconf_ignored(hass: HomeAssistant) -> None: with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, + return_value={"mac": "test-mac", "type": MODEL_1, "auth": False}, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -648,7 +649,7 @@ async def test_zeroconf_with_wifi_ap_ip(hass: HomeAssistant) -> None: with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, + return_value={"mac": "test-mac", "type": MODEL_1, "auth": False}, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -700,7 +701,7 @@ async def test_zeroconf_require_auth(hass: HomeAssistant, mock_block_device) -> with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": "SHSW-1", "auth": True}, + return_value={"mac": "test-mac", "type": MODEL_1, "auth": True}, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -726,7 +727,7 @@ async def test_zeroconf_require_auth(hass: HomeAssistant, mock_block_device) -> assert result2["title"] == "Test name" assert result2["data"] == { "host": "1.1.1.1", - "model": "SHSW-1", + "model": MODEL_1, "sleep_period": 0, "gen": 1, "username": "test username", @@ -754,7 +755,7 @@ async def test_reauth_successful( with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": "SHSW-1", "auth": True, "gen": gen}, + return_value={"mac": "test-mac", "type": MODEL_1, "auth": True, "gen": gen}, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -790,7 +791,7 @@ async def test_reauth_unsuccessful(hass: HomeAssistant, gen, user_input) -> None with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": "SHSW-1", "auth": True, "gen": gen}, + return_value={"mac": "test-mac", "type": MODEL_1, "auth": True, "gen": gen}, ), patch( "aioshelly.block_device.BlockDevice.create", new=AsyncMock(side_effect=InvalidAuthError), @@ -1029,7 +1030,7 @@ async def test_zeroconf_already_configured_triggers_refresh_mac_in_name( entry = MockConfigEntry( domain="shelly", unique_id="AABBCCDDEEFF", - data={"host": "1.1.1.1", "gen": 2, "sleep_period": 0, "model": "SHSW-1"}, + data={"host": "1.1.1.1", "gen": 2, "sleep_period": 0, "model": MODEL_1}, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -1038,7 +1039,7 @@ async def test_zeroconf_already_configured_triggers_refresh_mac_in_name( with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "", "type": "SHSW-1", "auth": False}, + return_value={"mac": "", "type": MODEL_1, "auth": False}, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -1061,7 +1062,7 @@ async def test_zeroconf_already_configured_triggers_refresh( entry = MockConfigEntry( domain="shelly", unique_id="AABBCCDDEEFF", - data={"host": "1.1.1.1", "gen": 2, "sleep_period": 0, "model": "SHSW-1"}, + data={"host": "1.1.1.1", "gen": 2, "sleep_period": 0, "model": MODEL_1}, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -1070,7 +1071,7 @@ async def test_zeroconf_already_configured_triggers_refresh( with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "AABBCCDDEEFF", "type": "SHSW-1", "auth": False}, + return_value={"mac": "AABBCCDDEEFF", "type": MODEL_1, "auth": False}, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -1093,7 +1094,7 @@ async def test_zeroconf_sleeping_device_not_triggers_refresh( entry = MockConfigEntry( domain="shelly", unique_id="AABBCCDDEEFF", - data={"host": "1.1.1.1", "gen": 2, "sleep_period": 1000, "model": "SHSW-1"}, + data={"host": "1.1.1.1", "gen": 2, "sleep_period": 1000, "model": MODEL_1}, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -1105,7 +1106,7 @@ async def test_zeroconf_sleeping_device_not_triggers_refresh( with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "AABBCCDDEEFF", "type": "SHSW-1", "auth": False}, + return_value={"mac": "AABBCCDDEEFF", "type": MODEL_1, "auth": False}, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -1148,7 +1149,7 @@ async def test_sleeping_device_gen2_with_new_firmware( assert result["data"] == { "host": "1.1.1.1", - "model": "SNSW-002P16EU", + "model": MODEL_PLUS_2PM, "sleep_period": 666, "gen": 2, } diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 8ce80b70032..e73168c6b20 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch +from aioshelly.const import MODEL_BULB, MODEL_BUTTON1 from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from freezegun.api import FrozenDateTimeFactory @@ -79,7 +80,7 @@ async def test_block_no_reload_on_bulb_changes( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: """Test block no reload on bulb mode/effect change.""" - await init_integration(hass, 1, model="SHBLB-1") + await init_integration(hass, 1, model=MODEL_BULB) monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "cfgChanged", 1) mock_block_device.mock_update() @@ -284,7 +285,7 @@ async def test_block_button_click_event( "sensor_ids", {"inputEvent": "S", "inputEventCnt": 0}, ) - entry = await init_integration(hass, 1, model="SHBTN-1", sleep_period=1000) + entry = await init_integration(hass, 1, model=MODEL_BUTTON1, sleep_period=1000) # Make device online mock_block_device.mock_update() diff --git a/tests/components/shelly/test_device_trigger.py b/tests/components/shelly/test_device_trigger.py index 143501ef620..9a63e66980a 100644 --- a/tests/components/shelly/test_device_trigger.py +++ b/tests/components/shelly/test_device_trigger.py @@ -1,4 +1,5 @@ """The tests for Shelly device triggers.""" +from aioshelly.const import MODEL_BUTTON1 import pytest from pytest_unordered import unordered @@ -108,7 +109,7 @@ async def test_get_triggers_rpc_device(hass: HomeAssistant, mock_rpc_device) -> async def test_get_triggers_button(hass: HomeAssistant, mock_block_device) -> None: """Test we get the expected triggers from a shelly button.""" - entry = await init_integration(hass, 1, model="SHBTN-1") + entry = await init_integration(hass, 1, model=MODEL_BUTTON1) dev_reg = async_get_dev_reg(hass) device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] diff --git a/tests/components/shelly/test_diagnostics.py b/tests/components/shelly/test_diagnostics.py index 39f1ef8d723..13126db0a0e 100644 --- a/tests/components/shelly/test_diagnostics.py +++ b/tests/components/shelly/test_diagnostics.py @@ -2,6 +2,7 @@ from unittest.mock import ANY from aioshelly.ble.const import BLE_SCAN_RESULT_EVENT +from aioshelly.const import MODEL_25 from homeassistant.components.diagnostics import REDACTED from homeassistant.components.shelly.const import ( @@ -40,7 +41,7 @@ async def test_block_config_entry_diagnostics( "bluetooth": "not initialized", "device_info": { "name": "Test name", - "model": "SHSW-25", + "model": MODEL_25, "sw_version": "some fw string", }, "device_settings": {"coiot": {"update_period": 15}}, @@ -136,7 +137,7 @@ async def test_rpc_config_entry_diagnostics( }, "device_info": { "name": "Test name", - "model": "SHSW-25", + "model": MODEL_25, "sw_version": "some fw string", }, "device_settings": {}, diff --git a/tests/components/shelly/test_event.py b/tests/components/shelly/test_event.py index b7824d8d7ac..09439adc6f7 100644 --- a/tests/components/shelly/test_event.py +++ b/tests/components/shelly/test_event.py @@ -1,6 +1,7 @@ """Tests for Shelly button platform.""" from __future__ import annotations +from aioshelly.const import MODEL_I3 from pytest_unordered import unordered from homeassistant.components.event import ( @@ -104,7 +105,7 @@ async def test_block_event(hass: HomeAssistant, monkeypatch, mock_block_device) async def test_block_event_shix3_1(hass: HomeAssistant, mock_block_device) -> None: """Test block device event for SHIX3-1.""" - await init_integration(hass, 1, model="SHIX3-1") + await init_integration(hass, 1, model=MODEL_I3) entity_id = "event.test_name_channel_1" state = hass.states.get(entity_id) diff --git a/tests/components/shelly/test_light.py b/tests/components/shelly/test_light.py index 69d0fccf421..e3aea966230 100644 --- a/tests/components/shelly/test_light.py +++ b/tests/components/shelly/test_light.py @@ -1,4 +1,13 @@ """Tests for Shelly light platform.""" +from aioshelly.const import ( + MODEL_BULB, + MODEL_BULB_RGBW, + MODEL_DIMMER, + MODEL_DIMMER_2, + MODEL_DUO, + MODEL_RGBW2, + MODEL_VINTAGE_V2, +) import pytest from homeassistant.components.light import ( @@ -33,7 +42,7 @@ LIGHT_BLOCK_ID = 2 async def test_block_device_rgbw_bulb(hass: HomeAssistant, mock_block_device) -> None: """Test block device RGBW bulb.""" - await init_integration(hass, 1, model="SHBLB-1") + await init_integration(hass, 1, model=MODEL_BULB) # Test initial state = hass.states.get("light.test_name_channel_1") @@ -113,7 +122,7 @@ async def test_block_device_rgb_bulb( ) -> None: """Test block device RGB bulb.""" monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "mode") - await init_integration(hass, 1, model="SHCB-1") + await init_integration(hass, 1, model=MODEL_BULB_RGBW) # Test initial state = hass.states.get("light.test_name_channel_1") @@ -215,7 +224,7 @@ async def test_block_device_white_bulb( monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "mode") monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "colorTemp") monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "effect") - await init_integration(hass, 1, model="SHVIN-1") + await init_integration(hass, 1, model=MODEL_VINTAGE_V2) # Test initial state = hass.states.get("light.test_name_channel_1") @@ -259,12 +268,12 @@ async def test_block_device_white_bulb( @pytest.mark.parametrize( "model", [ - "SHBDUO-1", - "SHCB-1", - "SHDM-1", - "SHDM-2", - "SHRGBW2", - "SHVIN-1", + MODEL_DUO, + MODEL_BULB_RGBW, + MODEL_DIMMER, + MODEL_DIMMER_2, + MODEL_RGBW2, + MODEL_VINTAGE_V2, ], ) async def test_block_device_support_transition( diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 115ad5edabb..e19416706e1 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -1,10 +1,13 @@ """Tests for Shelly switch platform.""" +from copy import deepcopy from unittest.mock import AsyncMock +from aioshelly.const import MODEL_GAS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest -from homeassistant.components.shelly.const import DOMAIN +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.shelly.const import DOMAIN, MODEL_WALL_DISPLAY from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( @@ -19,7 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import init_integration +from . import init_integration, register_entity RELAY_BLOCK_ID = 0 GAS_VALVE_BLOCK_ID = 6 @@ -236,7 +239,7 @@ async def test_block_device_gas_valve( ) -> None: """Test block device Shelly Gas with Valve addon.""" registry = er.async_get(hass) - await init_integration(hass, 1, "SHGS-1") + await init_integration(hass, 1, MODEL_GAS) entity_id = "switch.test_name_valve" entry = registry.async_get(entity_id) @@ -277,3 +280,39 @@ async def test_block_device_gas_valve( assert state assert state.state == STATE_ON # valve is open assert state.attributes.get(ATTR_ICON) == "mdi:valve-open" + + +async def test_wall_display_thermostat_mode( + hass: HomeAssistant, + mock_rpc_device, +) -> None: + """Test Wall Display in thermostat mode.""" + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + # the switch entity should not be created, only the climate entity + assert hass.states.get("switch.test_name") is None + assert hass.states.get("climate.test_name") + + +async def test_wall_display_relay_mode( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_rpc_device, + monkeypatch, +) -> None: + """Test Wall Display in thermostat mode.""" + entity_id = register_entity( + hass, + CLIMATE_DOMAIN, + "test_name", + "thermostat:0", + ) + + new_shelly = deepcopy(mock_rpc_device.shelly) + new_shelly["relay_in_thermostat"] = False + monkeypatch.setattr(mock_rpc_device, "shelly", new_shelly) + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + # the climate entity should be removed + assert hass.states.get(entity_id) is None diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index 454afb73ce1..06eac49e293 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -5,11 +5,16 @@ from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCal from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.shelly.const import DOMAIN +from homeassistant.components.shelly.const import ( + DOMAIN, + GEN1_RELEASE_URL, + GEN2_RELEASE_URL, +) from homeassistant.components.update import ( ATTR_IN_PROGRESS, ATTR_INSTALLED_VERSION, ATTR_LATEST_VERSION, + ATTR_RELEASE_URL, DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL, UpdateEntityFeature, @@ -75,6 +80,7 @@ async def test_block_update( assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" assert state.attributes[ATTR_IN_PROGRESS] is True + assert state.attributes[ATTR_RELEASE_URL] == GEN1_RELEASE_URL monkeypatch.setitem(mock_block_device.status["update"], "old_version", "2") await mock_rest_update(hass, freezer) @@ -117,6 +123,7 @@ async def test_block_beta_update( assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2b" assert state.attributes[ATTR_IN_PROGRESS] is False + assert state.attributes[ATTR_RELEASE_URL] is None await hass.services.async_call( UPDATE_DOMAIN, @@ -270,6 +277,7 @@ async def test_rpc_update(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" assert state.attributes[ATTR_IN_PROGRESS] == 0 + assert state.attributes[ATTR_RELEASE_URL] == GEN2_RELEASE_URL inject_rpc_device_event( monkeypatch, @@ -341,6 +349,7 @@ async def test_rpc_sleeping_update( assert state.attributes[ATTR_LATEST_VERSION] == "2" assert state.attributes[ATTR_IN_PROGRESS] is False assert state.attributes[ATTR_SUPPORTED_FEATURES] == UpdateEntityFeature(0) + assert state.attributes[ATTR_RELEASE_URL] == GEN2_RELEASE_URL monkeypatch.setitem(mock_rpc_device.shelly, "ver", "2") mock_rpc_device.mock_update() @@ -467,6 +476,7 @@ async def test_rpc_beta_update( assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "1" assert state.attributes[ATTR_IN_PROGRESS] is False + assert state.attributes[ATTR_RELEASE_URL] is None monkeypatch.setitem( mock_rpc_device.status["sys"], diff --git a/tests/components/shelly/test_utils.py b/tests/components/shelly/test_utils.py index 3d273ff3059..e47f9e451b4 100644 --- a/tests/components/shelly/test_utils.py +++ b/tests/components/shelly/test_utils.py @@ -1,12 +1,26 @@ """Tests for Shelly utils.""" +from aioshelly.const import ( + MODEL_1, + MODEL_1L, + MODEL_BUTTON1, + MODEL_BUTTON1_V2, + MODEL_DIMMER_2, + MODEL_EM3, + MODEL_I3, + MODEL_MOTION, + MODEL_PLUS_2PM_V2, + MODEL_WALL_DISPLAY, +) import pytest +from homeassistant.components.shelly.const import GEN1_RELEASE_URL, GEN2_RELEASE_URL from homeassistant.components.shelly.utils import ( get_block_channel_name, get_block_device_sleep_period, get_block_input_triggers, get_device_uptime, get_number_of_channels, + get_release_url, get_rpc_channel_name, get_rpc_input_triggers, is_block_momentary_input, @@ -39,7 +53,7 @@ async def test_block_get_number_of_channels(mock_block_device, monkeypatch) -> N == 4 ) - monkeypatch.setitem(mock_block_device.settings["device"], "type", "SHDM-2") + monkeypatch.setitem(mock_block_device.settings["device"], "type", MODEL_DIMMER_2) assert ( get_number_of_channels( mock_block_device, @@ -61,7 +75,7 @@ async def test_block_get_block_channel_name(mock_block_device, monkeypatch) -> N == "Test name channel 1" ) - monkeypatch.setitem(mock_block_device.settings["device"], "type", "SHEM-3") + monkeypatch.setitem(mock_block_device.settings["device"], "type", MODEL_EM3) assert ( get_block_channel_name( @@ -107,7 +121,7 @@ async def test_is_block_momentary_input(mock_block_device, monkeypatch) -> None: ) monkeypatch.setitem(mock_block_device.settings, "mode", "relay") - monkeypatch.setitem(mock_block_device.settings["device"], "type", "SHSW-L") + monkeypatch.setitem(mock_block_device.settings["device"], "type", MODEL_1L) assert ( is_block_momentary_input( mock_block_device.settings, mock_block_device.blocks[DEVICE_BLOCK_ID], True @@ -125,7 +139,7 @@ async def test_is_block_momentary_input(mock_block_device, monkeypatch) -> None: is False ) - monkeypatch.setitem(mock_block_device.settings["device"], "type", "SHBTN-2") + monkeypatch.setitem(mock_block_device.settings["device"], "type", MODEL_BUTTON1_V2) assert ( is_block_momentary_input( @@ -177,7 +191,7 @@ async def test_get_block_input_triggers(mock_block_device, monkeypatch) -> None: ) ) == {("long", "button"), ("single", "button")} - monkeypatch.setitem(mock_block_device.settings["device"], "type", "SHBTN-1") + monkeypatch.setitem(mock_block_device.settings["device"], "type", MODEL_BUTTON1) assert set( get_block_input_triggers( mock_block_device, mock_block_device.blocks[DEVICE_BLOCK_ID] @@ -189,7 +203,7 @@ async def test_get_block_input_triggers(mock_block_device, monkeypatch) -> None: ("triple", "button"), } - monkeypatch.setitem(mock_block_device.settings["device"], "type", "SHIX3-1") + monkeypatch.setitem(mock_block_device.settings["device"], "type", MODEL_I3) assert set( get_block_input_triggers( mock_block_device, mock_block_device.blocks[DEVICE_BLOCK_ID] @@ -224,3 +238,23 @@ async def test_get_rpc_input_triggers(mock_rpc_device, monkeypatch) -> None: monkeypatch.setattr(mock_rpc_device, "config", {"input:0": {"type": "switch"}}) assert not get_rpc_input_triggers(mock_rpc_device) + + +@pytest.mark.parametrize( + ("gen", "model", "beta", "expected"), + [ + (1, MODEL_MOTION, False, None), + (1, MODEL_1, False, GEN1_RELEASE_URL), + (1, MODEL_1, True, None), + (2, MODEL_WALL_DISPLAY, False, None), + (2, MODEL_PLUS_2PM_V2, False, GEN2_RELEASE_URL), + (2, MODEL_PLUS_2PM_V2, True, None), + ], +) +def test_get_release_url( + gen: int, model: str, beta: bool, expected: str | None +) -> None: + """Test get_release_url() with a device without a release note URL.""" + result = get_release_url(gen, model, beta) + + assert result is expected diff --git a/tests/components/shopping_list/test_todo.py b/tests/components/shopping_list/test_todo.py index 681ccea60ac..7722bd8b6da 100644 --- a/tests/components/shopping_list/test_todo.py +++ b/tests/components/shopping_list/test_todo.py @@ -13,39 +13,22 @@ from tests.typing import WebSocketGenerator TEST_ENTITY = "todo.shopping_list" -@pytest.fixture -def ws_req_id() -> Callable[[], int]: - """Fixture for incremental websocket requests.""" - - id = 0 - - def next() -> int: - nonlocal id - id += 1 - return id - - return next - - @pytest.fixture async def ws_get_items( - hass_ws_client: WebSocketGenerator, ws_req_id: Callable[[], int] + hass_ws_client: WebSocketGenerator, ) -> Callable[[], Awaitable[dict[str, str]]]: """Fixture to fetch items from the todo websocket.""" async def get() -> list[dict[str, str]]: # Fetch items using To-do platform client = await hass_ws_client() - id = ws_req_id() - await client.send_json( + await client.send_json_auto_id( { - "id": id, "type": "todo/item/list", "entity_id": TEST_ENTITY, } ) resp = await client.receive_json() - assert resp.get("id") == id assert resp.get("success") return resp.get("result", {}).get("items", []) @@ -55,25 +38,21 @@ async def ws_get_items( @pytest.fixture async def ws_move_item( hass_ws_client: WebSocketGenerator, - ws_req_id: Callable[[], int], ) -> Callable[[str, str | None], Awaitable[None]]: """Fixture to move an item in the todo list.""" async def move(uid: str, previous_uid: str | None) -> dict[str, Any]: # Fetch items using To-do platform client = await hass_ws_client() - id = ws_req_id() data = { - "id": id, "type": "todo/item/move", "entity_id": TEST_ENTITY, "uid": uid, } if previous_uid is not None: data["previous_uid"] = previous_uid - await client.send_json(data) + await client.send_json_auto_id(data) resp = await client.receive_json() - assert resp.get("id") == id return resp return move @@ -83,7 +62,6 @@ async def test_get_items( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, sl_setup: None, - ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: """Test creating a shopping list item with the WS API and verifying with To-do API.""" @@ -94,9 +72,7 @@ async def test_get_items( assert state.state == "0" # Native shopping list websocket - await client.send_json( - {"id": ws_req_id(), "type": "shopping_list/items/add", "name": "soda"} - ) + await client.send_json_auto_id({"type": "shopping_list/items/add", "name": "soda"}) msg = await client.receive_json() assert msg["success"] is True data = msg["result"] @@ -117,7 +93,6 @@ async def test_get_items( async def test_add_item( hass: HomeAssistant, sl_setup: None, - ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: """Test adding shopping_list item and listing it.""" @@ -145,7 +120,6 @@ async def test_add_item( async def test_remove_item( hass: HomeAssistant, sl_setup: None, - ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: """Test removing a todo item.""" @@ -187,7 +161,6 @@ async def test_remove_item( async def test_bulk_remove( hass: HomeAssistant, sl_setup: None, - ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: """Test removing a todo item.""" @@ -232,7 +205,6 @@ async def test_bulk_remove( async def test_update_item( hass: HomeAssistant, sl_setup: None, - ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: """Test updating a todo item.""" @@ -286,7 +258,6 @@ async def test_update_item( async def test_partial_update_item( hass: HomeAssistant, sl_setup: None, - ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: """Test updating a todo item with partial information.""" @@ -363,7 +334,6 @@ async def test_partial_update_item( async def test_update_invalid_item( hass: HomeAssistant, sl_setup: None, - ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: """Test updating a todo item that does not exist.""" @@ -410,7 +380,6 @@ async def test_update_invalid_item( async def test_move_item( hass: HomeAssistant, sl_setup: None, - ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ws_move_item: Callable[[str, str | None], Awaitable[dict[str, Any]]], src_idx: int, @@ -475,3 +444,69 @@ async def test_move_invalid_item( assert not resp.get("success") assert resp.get("error", {}).get("code") == "failed" assert "could not be re-ordered" in resp.get("error", {}).get("message") + + +async def test_subscribe_item( + hass: HomeAssistant, + sl_setup: None, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test updating a todo item.""" + + # Create new item + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + { + "item": "soda", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Subscribe and get the initial list + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "todo/item/subscribe", + "entity_id": TEST_ENTITY, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "soda" + assert items[0]["status"] == "needs_action" + uid = items[0]["uid"] + assert uid + + # Rename item item completed + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + { + "item": "soda", + "rename": "milk", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify update is published + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "milk" + assert items[0]["status"] == "needs_action" + assert "uid" in items[0] diff --git a/tests/components/simplisafe/conftest.py b/tests/components/simplisafe/conftest.py index 4b8686d7a7f..1b9f9f02cee 100644 --- a/tests/components/simplisafe/conftest.py +++ b/tests/components/simplisafe/conftest.py @@ -106,7 +106,8 @@ async def setup_simplisafe_fixture(hass, api, config): ), patch( "homeassistant.components.simplisafe.SimpliSafe._async_start_websocket_loop" ), patch( - "homeassistant.components.simplisafe.PLATFORMS", [] + "homeassistant.components.simplisafe.PLATFORMS", + [], ): assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() diff --git a/tests/components/simplisafe/test_init.py b/tests/components/simplisafe/test_init.py index 617b77f7c98..cc7b2b8d2b6 100644 --- a/tests/components/simplisafe/test_init.py +++ b/tests/components/simplisafe/test_init.py @@ -34,7 +34,8 @@ async def test_base_station_migration( ), patch( "homeassistant.components.simplisafe.SimpliSafe._async_start_websocket_loop" ), patch( - "homeassistant.components.simplisafe.PLATFORMS", [] + "homeassistant.components.simplisafe.PLATFORMS", + [], ): assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() diff --git a/tests/components/skybell/__init__.py b/tests/components/skybell/__init__.py index fc049adcc3d..ae9b6d132e4 100644 --- a/tests/components/skybell/__init__.py +++ b/tests/components/skybell/__init__.py @@ -1,12 +1 @@ """Tests for the SkyBell integration.""" - -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD - -USERNAME = "user" -PASSWORD = "password" -USER_ID = "123456789012345678901234" - -CONF_CONFIG_FLOW = { - CONF_EMAIL: USERNAME, - CONF_PASSWORD: PASSWORD, -} diff --git a/tests/components/skybell/conftest.py b/tests/components/skybell/conftest.py index 4318fa8c24f..beb3fec9b98 100644 --- a/tests/components/skybell/conftest.py +++ b/tests/components/skybell/conftest.py @@ -1,11 +1,28 @@ -"""Test setup for the SkyBell integration.""" - +"""Configure pytest for Skybell tests.""" from unittest.mock import AsyncMock, patch from aioskybell import Skybell, SkybellDevice +from aioskybell.helpers.const import BASE_URL, USERS_ME_URL +import orjson import pytest -from . import USER_ID +from homeassistant.components.skybell.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + +USERNAME = "user" +PASSWORD = "password" +USER_ID = "1234567890abcdef12345678" +DEVICE_ID = "012345670123456789abcdef" + +CONF_DATA = { + CONF_EMAIL: USERNAME, + CONF_PASSWORD: PASSWORD, +} @pytest.fixture(autouse=True) @@ -23,3 +40,88 @@ def skybell_mock(): return_value=mocked_skybell, ), patch("homeassistant.components.skybell.Skybell", return_value=mocked_skybell): yield mocked_skybell + + +def create_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create fixture for adding config entry in Home Assistant.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id=USER_ID, data=CONF_DATA) + entry.add_to_hass(hass) + return entry + + +async def set_aioclient_responses(aioclient_mock: AiohttpClientMocker) -> None: + """Set AioClient responses.""" + aioclient_mock.get( + f"{BASE_URL}devices/{DEVICE_ID}/info/", + text=load_fixture("skybell/device_info.json"), + ) + aioclient_mock.get( + f"{BASE_URL}devices/{DEVICE_ID}/settings/", + text=load_fixture("skybell/device_settings.json"), + ) + aioclient_mock.get( + f"{BASE_URL}devices/{DEVICE_ID}/activities/", + text=load_fixture("skybell/activities.json"), + ) + aioclient_mock.get( + f"{BASE_URL}devices/", + text=load_fixture("skybell/device.json"), + ) + aioclient_mock.get( + USERS_ME_URL, + text=load_fixture("skybell/me.json"), + ) + aioclient_mock.post( + f"{BASE_URL}login/", + text=load_fixture("skybell/login.json"), + ) + aioclient_mock.get( + f"{BASE_URL}devices/{DEVICE_ID}/activities/1234567890ab1234567890ac/video/", + text=load_fixture("skybell/video.json"), + ) + aioclient_mock.get( + f"{BASE_URL}devices/{DEVICE_ID}/avatar/", + text=load_fixture("skybell/avatar.json"), + ) + aioclient_mock.get( + f"https://v3-production-devices-avatar.s3.us-west-2.amazonaws.com/{DEVICE_ID}.jpg", + ) + aioclient_mock.get( + f"https://skybell-thumbnails-stage.s3.amazonaws.com/{DEVICE_ID}/1646859244793-951{DEVICE_ID}_{DEVICE_ID}.jpeg", + ) + + +@pytest.fixture +async def connection(aioclient_mock: AiohttpClientMocker) -> None: + """Fixture for good connection responses.""" + await set_aioclient_responses(aioclient_mock) + + +def create_skybell(hass: HomeAssistant) -> Skybell: + """Create Skybell object.""" + skybell = Skybell( + username=USERNAME, + password=PASSWORD, + get_devices=True, + session=async_get_clientsession(hass), + ) + skybell._cache = orjson.loads(load_fixture("skybell/cache.json")) + return skybell + + +def mock_skybell(hass: HomeAssistant): + """Mock Skybell object.""" + return patch( + "homeassistant.components.skybell.Skybell", return_value=create_skybell(hass) + ) + + +async def async_init_integration(hass: HomeAssistant) -> MockConfigEntry: + """Set up the Skybell integration in Home Assistant.""" + config_entry = create_entry(hass) + + with mock_skybell(hass), patch("aioskybell.utils.async_save_cache"): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/skybell/fixtures/activities.json b/tests/components/skybell/fixtures/activities.json new file mode 100644 index 00000000000..4ed5c027821 --- /dev/null +++ b/tests/components/skybell/fixtures/activities.json @@ -0,0 +1,30 @@ +[ + { + "videoState": "download:ready", + "_id": "1234567890ab1234567890ab", + "device": "0123456789abcdef01234567", + "callId": "1234567890123-1234567890abcd1234567890abcd", + "event": "device:sensor:motion", + "state": "ready", + "ttlStartDate": "2020-03-30T12:35:02.204Z", + "createdAt": "2020-03-30T12:35:02.204Z", + "updatedAt": "2020-03-30T12:35:02.566Z", + "id": "1234567890ab1234567890ab", + "media": "https://skybell-thumbnails-stage.s3.amazonaws.com/012345670123456789abcdef/1646859244793-951012345670123456789abcdef_012345670123456789abcdef.jpeg", + "mediaSmall": "https://skybell-thumbnails-stage.s3.amazonaws.com/012345670123456789abcdef/1646859244793-951012345670123456789abcdef_012345670123456789abcdef_small.jpeg" + }, + { + "videoState": "download:ready", + "_id": "1234567890ab1234567890a9", + "device": "0123456789abcdef01234567", + "callId": "1234567890123-1234567890abcd1234567890abc9", + "event": "application:on-demand", + "state": "ready", + "ttlStartDate": "2020-03-30T11:35:02.204Z", + "createdAt": "2020-03-30T11:35:02.204Z", + "updatedAt": "2020-03-30T11:35:02.566Z", + "id": "1234567890ab1234567890a9", + "media": "https://skybell-thumbnails-stage.s3.amazonaws.com/012345670123456789abcdef/1646859244793-951012345670123456789abcdef_012345670123456789abcde9.jpeg", + "mediaSmall": "https://skybell-thumbnails-stage.s3.amazonaws.com/012345670123456789abcdef/1646859244793-951012345670123456789abcdef_012345670123456789abcde9_small.jpeg" + } +] diff --git a/tests/components/skybell/fixtures/avatar.json b/tests/components/skybell/fixtures/avatar.json new file mode 100644 index 00000000000..3f8157c15c8 --- /dev/null +++ b/tests/components/skybell/fixtures/avatar.json @@ -0,0 +1,4 @@ +{ + "createdAt": "2020-03-31T04:13:48.640Z", + "url": "https://v3-production-devices-avatar.s3.us-west-2.amazonaws.com/012345670123456789abcdef.jpg" +} diff --git a/tests/components/skybell/fixtures/cache.json b/tests/components/skybell/fixtures/cache.json new file mode 100644 index 00000000000..1276c2cfc0f --- /dev/null +++ b/tests/components/skybell/fixtures/cache.json @@ -0,0 +1,40 @@ +{ + "app_id": "secret", + "client_id": "secret", + "token": "secret", + "access_token": "secret", + "devices": { + "5f8ef594362f31000833d959": { + "event": { + "device:sensor:motion": { + "videoState": "download:ready", + "_id": "1234567890ab1234567890ab", + "device": "0123456789abcdef01234567", + "callId": "1234567890123-1234567890abcd1234567890abcd", + "event": "device:sensor:motion", + "state": "ready", + "ttlStartDate": "2020-03-30T12:35:02.204Z", + "createdAt": "2020-03-30T12:35:02.204Z", + "updatedAt": "2020-03-30T12:35:02.566Z", + "id": "1234567890ab1234567890ab", + "media": "https://skybell-thumbnails-stage.s3.amazonaws.com/012345670123456789abcdef/1646859244793-951012345670123456789abcdef_012345670123456789abcdef.jpeg", + "mediaSmall": "https://skybell-thumbnails-stage.s3.amazonaws.com/012345670123456789abcdef/1646859244793-951012345670123456789abcdef_012345670123456789abcdef_small.jpeg" + }, + "device:sensor:button": { + "videoState": "download:ready", + "_id": "1234567890ab1234567890a9", + "device": "0123456789abcdef01234567", + "callId": "1234567890123-1234567890abcd1234567890abc9", + "event": "application:on-demand", + "state": "ready", + "ttlStartDate": "2020-03-30T11:35:02.204Z", + "createdAt": "2020-03-30T11:35:02.204Z", + "updatedAt": "2020-03-30T11:35:02.566Z", + "id": "1234567890ab1234567890a9", + "media": "https://skybell-thumbnails-stage.s3.amazonaws.com/012345670123456789abcdef/1646859244793-951012345670123456789abcdef_012345670123456789abcde9.jpeg", + "mediaSmall": "https://skybell-thumbnails-stage.s3.amazonaws.com/012345670123456789abcdef/1646859244793-951012345670123456789abcdef_012345670123456789abcde9_small.jpeg" + } + } + } + } +} diff --git a/tests/components/skybell/fixtures/device.json b/tests/components/skybell/fixtures/device.json new file mode 100644 index 00000000000..7b522aa687d --- /dev/null +++ b/tests/components/skybell/fixtures/device.json @@ -0,0 +1,19 @@ +[ + { + "user": "0123456789abcdef01234567", + "uuid": "0123456789", + "resourceId": "012345670123456789abcdef", + "deviceInviteToken": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "location": { + "lat": "-1.0", + "lng": "1.0" + }, + "name": "Front Door", + "type": "skybell hd", + "status": "up", + "createdAt": "2020-10-20T14:35:00.745Z", + "updatedAt": "2020-10-20T14:35:00.745Z", + "id": "012345670123456789abcdef", + "acl": "owner" + } +] diff --git a/tests/components/skybell/fixtures/device_info.json b/tests/components/skybell/fixtures/device_info.json new file mode 100644 index 00000000000..d858bb20e36 --- /dev/null +++ b/tests/components/skybell/fixtures/device_info.json @@ -0,0 +1,25 @@ +{ + "essid": "wifi", + "wifiBitrate": "39", + "proxy_port": "5683", + "wifiLinkQuality": "43", + "port": "5683", + "mac": "ff:ff:ff:ff:ff:ff", + "serialNo": "0123456789", + "wifiTxPwrEeprom": "12", + "region": "us-west-2", + "hardwareRevision": "SKYBELL_TRIMPLUS_1000030-F", + "proxy_address": "34.209.204.201", + "wifiSignalLevel": "-67", + "localHostname": "ip-10-0-0-67.us-west-2.compute.internal", + "wifiNoise": "0", + "address": "1.2.3.4", + "clientId": "1234567890abcdef1234567890abcdef1234567890abcdef", + "timestamp": "60000000000", + "deviceId": "01234567890abcdef1234567", + "firmwareVersion": "7082", + "checkedInAt": "2020-03-31T04:13:37.000Z", + "status": { + "wifiLink": "poor" + } +} diff --git a/tests/components/skybell/fixtures/device_settings.json b/tests/components/skybell/fixtures/device_settings.json new file mode 100644 index 00000000000..46af5f0bd4b --- /dev/null +++ b/tests/components/skybell/fixtures/device_settings.json @@ -0,0 +1,22 @@ +{ + "ring_tone": "0", + "do_not_ring": "false", + "do_not_disturb": "false", + "digital_doorbell": "false", + "video_profile": "1", + "mic_volume": "63", + "speaker_volume": "96", + "chime_level": "1", + "motion_threshold": "32", + "low_lux_threshold": "50", + "med_lux_threshold": "150", + "high_lux_threshold": "400", + "low_front_led_dac": "10", + "med_front_led_dac": "10", + "high_front_led_dac": "10", + "green_r": "0", + "green_g": "0", + "green_b": "255", + "led_intensity": "0", + "motion_policy": "call" +} diff --git a/tests/components/skybell/fixtures/device_settings_change.json b/tests/components/skybell/fixtures/device_settings_change.json new file mode 100644 index 00000000000..6e2c8dd199b --- /dev/null +++ b/tests/components/skybell/fixtures/device_settings_change.json @@ -0,0 +1,22 @@ +{ + "ring_tone": "0", + "do_not_ring": "false", + "do_not_disturb": "false", + "digital_doorbell": "false", + "video_profile": "1", + "mic_volume": "63", + "speaker_volume": "96", + "chime_level": "1", + "motion_threshold": "32", + "low_lux_threshold": "50", + "med_lux_threshold": "150", + "high_lux_threshold": "400", + "low_front_led_dac": "10", + "med_front_led_dac": "10", + "high_front_led_dac": "10", + "green_r": "10", + "green_g": "125", + "green_b": "255", + "led_intensity": "50", + "motion_policy": "disabled" +} diff --git a/tests/components/skybell/fixtures/login.json b/tests/components/skybell/fixtures/login.json new file mode 100644 index 00000000000..c7eaa44b5ab --- /dev/null +++ b/tests/components/skybell/fixtures/login.json @@ -0,0 +1,10 @@ +{ + "firstName": "John", + "lastName": "Doe", + "resourceId": "0123456789abcdef01234567", + "createdAt": "2018-07-06T02:02:14.050Z", + "updatedAt": "2018-07-06T02:02:14.050Z", + "id": "0123456789abcdef01234567", + "userLinks": [], + "access_token": "superlongkey" +} diff --git a/tests/components/skybell/fixtures/login_401.json b/tests/components/skybell/fixtures/login_401.json new file mode 100644 index 00000000000..ab6bfd7053c --- /dev/null +++ b/tests/components/skybell/fixtures/login_401.json @@ -0,0 +1,5 @@ +{ + "errors": { + "message": "Invalid Login - SmartAuth" + } +} diff --git a/tests/components/skybell/fixtures/me.json b/tests/components/skybell/fixtures/me.json new file mode 100644 index 00000000000..7b27c95ec01 --- /dev/null +++ b/tests/components/skybell/fixtures/me.json @@ -0,0 +1,9 @@ +{ + "firstName": "First", + "lastName": "Last", + "resourceId": "123456789012345678901234", + "createdAt": "2018-10-06T02:02:14.050Z", + "updatedAt": "2018-10-06T02:02:14.050Z", + "id": "1234567890abcdef12345678", + "userLinks": [] +} diff --git a/tests/components/skybell/fixtures/video.json b/tests/components/skybell/fixtures/video.json new file mode 100644 index 00000000000..e674df1c9c8 --- /dev/null +++ b/tests/components/skybell/fixtures/video.json @@ -0,0 +1,3 @@ +{ + "url": "https://production-video-download.s3.us-west-2.amazonaws.com/012345670123456789abcdef/1654307756676-0123456789120123456789abcdef_012345670123456789abcdef.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=01234567890123456789%2F20203030%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20200330T201225Z&X-Amz-Expires=300&X-Amz-Signature=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef&X-Amz-SignedHeaders=host" +} diff --git a/tests/components/skybell/test_binary_sensor.py b/tests/components/skybell/test_binary_sensor.py new file mode 100644 index 00000000000..8e0bc884730 --- /dev/null +++ b/tests/components/skybell/test_binary_sensor.py @@ -0,0 +1,18 @@ +"""Binary sensor tests for the Skybell integration.""" +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from .conftest import async_init_integration + + +async def test_binary_sensors(hass: HomeAssistant, connection) -> None: + """Test we get sensor data.""" + await async_init_integration(hass) + + state = hass.states.get("binary_sensor.front_door_button") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.OCCUPANCY + state = hass.states.get("binary_sensor.front_door_motion") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.MOTION diff --git a/tests/components/skybell/test_config_flow.py b/tests/components/skybell/test_config_flow.py index f93c1d6ae4f..d83f4243d7f 100644 --- a/tests/components/skybell/test_config_flow.py +++ b/tests/components/skybell/test_config_flow.py @@ -11,7 +11,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import CONF_CONFIG_FLOW, PASSWORD, USER_ID +from .conftest import CONF_DATA, PASSWORD, USER_ID from tests.common import MockConfigEntry @@ -37,12 +37,12 @@ async def test_flow_user(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=CONF_CONFIG_FLOW, + user_input=CONF_DATA, ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "user" - assert result["data"] == CONF_CONFIG_FLOW + assert result["data"] == CONF_DATA assert result["result"].unique_id == USER_ID @@ -50,12 +50,12 @@ async def test_flow_user_already_configured(hass: HomeAssistant) -> None: """Test user initialized flow with duplicate server.""" entry = MockConfigEntry( domain=DOMAIN, - data=CONF_CONFIG_FLOW, + data=CONF_DATA, ) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW + DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA ) assert result["type"] == FlowResultType.ABORT @@ -66,7 +66,7 @@ async def test_flow_user_cannot_connect(hass: HomeAssistant, skybell_mock) -> No """Test user initialized flow with unreachable server.""" skybell_mock.async_initialize.side_effect = exceptions.SkybellException(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW + DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" @@ -79,7 +79,7 @@ async def test_invalid_credentials(hass: HomeAssistant, skybell_mock) -> None: exceptions.SkybellAuthenticationException(hass) ) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW + DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA ) assert result["type"] == FlowResultType.FORM @@ -91,7 +91,7 @@ async def test_flow_user_unknown_error(hass: HomeAssistant, skybell_mock) -> Non """Test user initialized flow with unreachable server.""" skybell_mock.async_initialize.side_effect = Exception result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW + DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" @@ -100,7 +100,7 @@ async def test_flow_user_unknown_error(hass: HomeAssistant, skybell_mock) -> Non async def test_step_reauth(hass: HomeAssistant) -> None: """Test the reauth flow.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=USER_ID, data=CONF_CONFIG_FLOW) + entry = MockConfigEntry(domain=DOMAIN, unique_id=USER_ID, data=CONF_DATA) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -126,7 +126,7 @@ async def test_step_reauth(hass: HomeAssistant) -> None: async def test_step_reauth_failed(hass: HomeAssistant, skybell_mock) -> None: """Test the reauth flow fails and recovers.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=USER_ID, data=CONF_CONFIG_FLOW) + entry = MockConfigEntry(domain=DOMAIN, unique_id=USER_ID, data=CONF_DATA) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( diff --git a/tests/components/smappee/test_config_flow.py b/tests/components/smappee/test_config_flow.py index f6f5ab66708..8d4d7b8c3b2 100644 --- a/tests/components/smappee/test_config_flow.py +++ b/tests/components/smappee/test_config_flow.py @@ -146,9 +146,7 @@ async def test_user_local_connection_error(hass: HomeAssistant) -> None: "pysmappee.mqtt.SmappeeLocalMqtt.start_attempt", return_value=True ), patch("pysmappee.mqtt.SmappeeLocalMqtt.start", return_value=True), patch( "pysmappee.mqtt.SmappeeLocalMqtt.stop", return_value=True - ), patch( - "pysmappee.mqtt.SmappeeLocalMqtt.is_config_ready", return_value=None - ): + ), patch("pysmappee.mqtt.SmappeeLocalMqtt.is_config_ready", return_value=None): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -474,9 +472,7 @@ async def test_full_zeroconf_flow(hass: HomeAssistant) -> None: ), patch( "pysmappee.api.SmappeeLocalApi.load_instantaneous", return_value=[{"key": "phase0ActivePower", "value": 0}], - ), patch( - "homeassistant.components.smappee.async_setup_entry", return_value=True - ): + ), patch("homeassistant.components.smappee.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, @@ -516,9 +512,7 @@ async def test_full_user_local_flow(hass: HomeAssistant) -> None: ), patch( "pysmappee.api.SmappeeLocalApi.load_instantaneous", return_value=[{"key": "phase0ActivePower", "value": 0}], - ), patch( - "homeassistant.components.smappee.async_setup_entry", return_value=True - ): + ), patch("homeassistant.components.smappee.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index ce875190efb..e74d69f04c9 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -15,16 +15,20 @@ from homeassistant.components.climate import ( ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_HVAC_MODES, + ATTR_PRESET_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, ClimateEntityFeature, HVACAction, HVACMode, ) +from homeassistant.components.climate.const import ATTR_SWING_MODE from homeassistant.components.smartthings import climate from homeassistant.components.smartthings.const import DOMAIN from homeassistant.const import ( @@ -155,6 +159,7 @@ def air_conditioner_fixture(device_factory): Capability.switch, Capability.temperature_measurement, Capability.thermostat_cooling_setpoint, + Capability.fan_oscillation_mode, ], status={ Attribute.air_conditioner_mode: "auto", @@ -182,6 +187,14 @@ def air_conditioner_fixture(device_factory): ], Attribute.switch: "on", Attribute.cooling_setpoint: 23, + "supportedAcOptionalMode": ["windFree"], + Attribute.supported_fan_oscillation_modes: [ + "all", + "horizontal", + "vertical", + "fixed", + ], + Attribute.fan_oscillation_mode: "vertical", }, ) device.status.attributes[Attribute.temperature] = Status(24, "C", None) @@ -303,7 +316,10 @@ async def test_air_conditioner_entity_state( assert state.state == HVACMode.HEAT_COOL assert ( state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + == ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.SWING_MODE ) assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ HVACMode.COOL, @@ -591,3 +607,40 @@ async def test_entity_and_device_attributes(hass: HomeAssistant, thermostat) -> assert entry.manufacturer == "Generic manufacturer" assert entry.hw_version == "v4.56" assert entry.sw_version == "v7.89" + + +async def test_set_windfree_off(hass: HomeAssistant, air_conditioner) -> None: + """Test if the windfree preset can be turned on and is turned off when fan mode is set.""" + entity_ids = ["climate.air_conditioner"] + air_conditioner.status.update_attribute_value(Attribute.switch, "on") + await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_ids, ATTR_PRESET_MODE: "windFree"}, + blocking=True, + ) + state = hass.states.get("climate.air_conditioner") + assert state.attributes[ATTR_PRESET_MODE] == "windFree" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: entity_ids, ATTR_FAN_MODE: "low"}, + blocking=True, + ) + state = hass.states.get("climate.air_conditioner") + assert not state.attributes[ATTR_PRESET_MODE] + + +async def test_set_swing_mode(hass: HomeAssistant, air_conditioner) -> None: + """Test the fan swing is set successfully.""" + await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) + entity_ids = ["climate.air_conditioner"] + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + {ATTR_ENTITY_ID: entity_ids, ATTR_SWING_MODE: "vertical"}, + blocking=True, + ) + state = hass.states.get("climate.air_conditioner") + assert state.attributes[ATTR_SWING_MODE] == "vertical" diff --git a/tests/components/smhi/snapshots/test_weather.ambr b/tests/components/smhi/snapshots/test_weather.ambr index ade151ed128..fa9d76c68ba 100644 --- a/tests/components/smhi/snapshots/test_weather.ambr +++ b/tests/components/smhi/snapshots/test_weather.ambr @@ -195,6 +195,418 @@ ]), }) # --- +# name: test_forecast_service[forecast] + dict({ + 'weather.smhi_test': dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-07T12:00:00', + 'humidity': 96, + 'precipitation': 0.0, + 'pressure': 991.0, + 'temperature': 18.0, + 'templow': 15.0, + 'wind_bearing': 114, + 'wind_gust_speed': 32.76, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-08T12:00:00', + 'humidity': 97, + 'precipitation': 10.6, + 'pressure': 984.0, + 'temperature': 15.0, + 'templow': 11.0, + 'wind_bearing': 183, + 'wind_gust_speed': 27.36, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-09T12:00:00', + 'humidity': 95, + 'precipitation': 6.3, + 'pressure': 1001.0, + 'temperature': 12.0, + 'templow': 11.0, + 'wind_bearing': 166, + 'wind_gust_speed': 48.24, + 'wind_speed': 18.0, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-10T12:00:00', + 'humidity': 75, + 'precipitation': 4.8, + 'pressure': 1011.0, + 'temperature': 14.0, + 'templow': 10.0, + 'wind_bearing': 174, + 'wind_gust_speed': 29.16, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-11T12:00:00', + 'humidity': 69, + 'precipitation': 0.6, + 'pressure': 1015.0, + 'temperature': 18.0, + 'templow': 12.0, + 'wind_bearing': 197, + 'wind_gust_speed': 27.36, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-12T12:00:00', + 'humidity': 82, + 'precipitation': 0.0, + 'pressure': 1014.0, + 'temperature': 17.0, + 'templow': 12.0, + 'wind_bearing': 225, + 'wind_gust_speed': 28.08, + 'wind_speed': 8.64, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-13T12:00:00', + 'humidity': 59, + 'precipitation': 0.0, + 'pressure': 1013.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 234, + 'wind_gust_speed': 35.64, + 'wind_speed': 14.76, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'partlycloudy', + 'datetime': '2023-08-14T12:00:00', + 'humidity': 56, + 'precipitation': 0.0, + 'pressure': 1015.0, + 'temperature': 21.0, + 'templow': 14.0, + 'wind_bearing': 216, + 'wind_gust_speed': 33.12, + 'wind_speed': 13.68, + }), + dict({ + 'cloud_coverage': 88, + 'condition': 'partlycloudy', + 'datetime': '2023-08-15T12:00:00', + 'humidity': 64, + 'precipitation': 3.6, + 'pressure': 1014.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 226, + 'wind_gust_speed': 33.12, + 'wind_speed': 13.68, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-16T12:00:00', + 'humidity': 61, + 'precipitation': 2.4, + 'pressure': 1014.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 233, + 'wind_gust_speed': 33.48, + 'wind_speed': 14.04, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[get_forecast] + dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-07T12:00:00', + 'humidity': 96, + 'precipitation': 0.0, + 'pressure': 991.0, + 'temperature': 18.0, + 'templow': 15.0, + 'wind_bearing': 114, + 'wind_gust_speed': 32.76, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-08T12:00:00', + 'humidity': 97, + 'precipitation': 10.6, + 'pressure': 984.0, + 'temperature': 15.0, + 'templow': 11.0, + 'wind_bearing': 183, + 'wind_gust_speed': 27.36, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-09T12:00:00', + 'humidity': 95, + 'precipitation': 6.3, + 'pressure': 1001.0, + 'temperature': 12.0, + 'templow': 11.0, + 'wind_bearing': 166, + 'wind_gust_speed': 48.24, + 'wind_speed': 18.0, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-10T12:00:00', + 'humidity': 75, + 'precipitation': 4.8, + 'pressure': 1011.0, + 'temperature': 14.0, + 'templow': 10.0, + 'wind_bearing': 174, + 'wind_gust_speed': 29.16, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-11T12:00:00', + 'humidity': 69, + 'precipitation': 0.6, + 'pressure': 1015.0, + 'temperature': 18.0, + 'templow': 12.0, + 'wind_bearing': 197, + 'wind_gust_speed': 27.36, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-12T12:00:00', + 'humidity': 82, + 'precipitation': 0.0, + 'pressure': 1014.0, + 'temperature': 17.0, + 'templow': 12.0, + 'wind_bearing': 225, + 'wind_gust_speed': 28.08, + 'wind_speed': 8.64, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-13T12:00:00', + 'humidity': 59, + 'precipitation': 0.0, + 'pressure': 1013.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 234, + 'wind_gust_speed': 35.64, + 'wind_speed': 14.76, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'partlycloudy', + 'datetime': '2023-08-14T12:00:00', + 'humidity': 56, + 'precipitation': 0.0, + 'pressure': 1015.0, + 'temperature': 21.0, + 'templow': 14.0, + 'wind_bearing': 216, + 'wind_gust_speed': 33.12, + 'wind_speed': 13.68, + }), + dict({ + 'cloud_coverage': 88, + 'condition': 'partlycloudy', + 'datetime': '2023-08-15T12:00:00', + 'humidity': 64, + 'precipitation': 3.6, + 'pressure': 1014.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 226, + 'wind_gust_speed': 33.12, + 'wind_speed': 13.68, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-16T12:00:00', + 'humidity': 61, + 'precipitation': 2.4, + 'pressure': 1014.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 233, + 'wind_gust_speed': 33.48, + 'wind_speed': 14.04, + }), + ]), + }) +# --- +# name: test_forecast_service[get_forecasts] + dict({ + 'weather.smhi_test': dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-07T12:00:00', + 'humidity': 96, + 'precipitation': 0.0, + 'pressure': 991.0, + 'temperature': 18.0, + 'templow': 15.0, + 'wind_bearing': 114, + 'wind_gust_speed': 32.76, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-08T12:00:00', + 'humidity': 97, + 'precipitation': 10.6, + 'pressure': 984.0, + 'temperature': 15.0, + 'templow': 11.0, + 'wind_bearing': 183, + 'wind_gust_speed': 27.36, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-09T12:00:00', + 'humidity': 95, + 'precipitation': 6.3, + 'pressure': 1001.0, + 'temperature': 12.0, + 'templow': 11.0, + 'wind_bearing': 166, + 'wind_gust_speed': 48.24, + 'wind_speed': 18.0, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-10T12:00:00', + 'humidity': 75, + 'precipitation': 4.8, + 'pressure': 1011.0, + 'temperature': 14.0, + 'templow': 10.0, + 'wind_bearing': 174, + 'wind_gust_speed': 29.16, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-11T12:00:00', + 'humidity': 69, + 'precipitation': 0.6, + 'pressure': 1015.0, + 'temperature': 18.0, + 'templow': 12.0, + 'wind_bearing': 197, + 'wind_gust_speed': 27.36, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-12T12:00:00', + 'humidity': 82, + 'precipitation': 0.0, + 'pressure': 1014.0, + 'temperature': 17.0, + 'templow': 12.0, + 'wind_bearing': 225, + 'wind_gust_speed': 28.08, + 'wind_speed': 8.64, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-13T12:00:00', + 'humidity': 59, + 'precipitation': 0.0, + 'pressure': 1013.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 234, + 'wind_gust_speed': 35.64, + 'wind_speed': 14.76, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'partlycloudy', + 'datetime': '2023-08-14T12:00:00', + 'humidity': 56, + 'precipitation': 0.0, + 'pressure': 1015.0, + 'temperature': 21.0, + 'templow': 14.0, + 'wind_bearing': 216, + 'wind_gust_speed': 33.12, + 'wind_speed': 13.68, + }), + dict({ + 'cloud_coverage': 88, + 'condition': 'partlycloudy', + 'datetime': '2023-08-15T12:00:00', + 'humidity': 64, + 'precipitation': 3.6, + 'pressure': 1014.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 226, + 'wind_gust_speed': 33.12, + 'wind_speed': 13.68, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-16T12:00:00', + 'humidity': 61, + 'precipitation': 2.4, + 'pressure': 1014.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 233, + 'wind_gust_speed': 33.48, + 'wind_speed': 14.04, + }), + ]), + }), + }) +# --- # name: test_forecast_services dict({ 'cloud_coverage': 100, diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py index 67aa18ea75d..f12aa92df3c 100644 --- a/tests/components/smhi/test_weather.py +++ b/tests/components/smhi/test_weather.py @@ -20,7 +20,8 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED_UNIT, DOMAIN as WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + LEGACY_SERVICE_GET_FORECAST, + SERVICE_GET_FORECASTS, ) from homeassistant.components.weather.const import ( ATTR_WEATHER_CLOUD_COVERAGE, @@ -443,11 +444,19 @@ async def test_forecast_services_lack_of_data( assert forecast1 is None +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) async def test_forecast_service( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str, snapshot: SnapshotAssertion, + service: str, ) -> None: """Test forecast service.""" uri = APIURL_TEMPLATE.format( @@ -463,7 +472,7 @@ async def test_forecast_service( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, {"entity_id": ENTITY_ID, "type": "daily"}, blocking=True, return_response=True, diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index 86a21c754ed..182b45d9c1b 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -1,4 +1,5 @@ """The tests for the notify smtp platform.""" +from pathlib import Path import re from unittest.mock import patch @@ -10,6 +11,7 @@ from homeassistant.components.smtp.const import DOMAIN from homeassistant.components.smtp.notify import MailNotificationService from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component from tests.common import get_fixture_path @@ -101,7 +103,7 @@ EMAIL_DATA = [ ( "Test msg", {"images": ["tests/testing_config/notify/test.jpg"]}, - "Content-Type: multipart/related", + "Content-Type: multipart/mixed", ), ( "Test msg", @@ -110,7 +112,7 @@ EMAIL_DATA = [ ), ( "Test msg", - {"html": HTML, "images": ["test.jpg"]}, + {"html": HTML, "images": ["tests/testing_config/notify/test_not_exists.jpg"]}, "Content-Type: multipart/related", ), ( @@ -132,15 +134,52 @@ EMAIL_DATA = [ ], ) def test_send_message( - message_data, data, content_type, hass: HomeAssistant, message + hass: HomeAssistant, message_data, data, content_type, message ) -> None: """Verify if we can send messages of all types correctly.""" sample_email = "" + message.hass = hass + hass.config.allowlist_external_dirs.add(Path("tests/testing_config").resolve()) with patch("email.utils.make_msgid", return_value=sample_email): result, _ = message.send_message(message_data, data=data) assert content_type in result +@pytest.mark.parametrize( + ("message_data", "data", "content_type"), + [ + ( + "Test msg", + {"images": ["tests/testing_config/notify/test.jpg"]}, + "Content-Type: multipart/mixed", + ), + ], +) +def test_sending_insecure_files_fails( + hass: HomeAssistant, + message_data, + data, + content_type, + message, +) -> None: + """Verify if we cannot send messages with insecure attachments.""" + sample_email = "" + message.hass = hass + with patch("email.utils.make_msgid", return_value=sample_email), pytest.raises( + ServiceValidationError + ) as exc: + result, _ = message.send_message(message_data, data=data) + assert content_type in result + assert exc.value.translation_key == "remote_path_not_allowed" + assert exc.value.translation_domain == DOMAIN + assert ( + str(exc.value.translation_placeholders["file_path"]) + == "tests/testing_config/notify" + ) + assert exc.value.translation_placeholders["url"] + assert exc.value.translation_placeholders["file_name"] == "test.jpg" + + def test_send_text_message(hass: HomeAssistant, message) -> None: """Verify if we can send simple text message.""" expected = ( diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index cb912af1cf6..648ca12803c 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -230,9 +230,9 @@ async def silent_ssdp_scanner(hass): ), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch( "homeassistant.components.ssdp.Scanner.async_scan" ), patch( - "homeassistant.components.ssdp.Server._async_start_upnp_servers" + "homeassistant.components.ssdp.Server._async_start_upnp_servers", ), patch( - "homeassistant.components.ssdp.Server._async_stop_upnp_servers" + "homeassistant.components.ssdp.Server._async_stop_upnp_servers", ): yield diff --git a/tests/components/subaru/conftest.py b/tests/components/subaru/conftest.py index 678e8ba5034..8bed67cb15f 100644 --- a/tests/components/subaru/conftest.py +++ b/tests/components/subaru/conftest.py @@ -145,9 +145,7 @@ async def setup_subaru_config_entry( return_value=vehicle_status, ), patch( MOCK_API_UPDATE, - ), patch( - MOCK_API_FETCH, side_effect=fetch_effect - ): + ), patch(MOCK_API_FETCH, side_effect=fetch_effect): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/switchbee/test_config_flow.py b/tests/components/switchbee/test_config_flow.py index 239777a4da3..98d413c3b96 100644 --- a/tests/components/switchbee/test_config_flow.py +++ b/tests/components/switchbee/test_config_flow.py @@ -39,9 +39,7 @@ async def test_form(hass: HomeAssistant, test_cucode_in_coordinator_data) -> Non return_value=True, ), patch( "switchbee.api.polling.CentralUnitPolling.fetch_states", return_value=None - ), patch( - "switchbee.api.polling.CentralUnitPolling._login", return_value=None - ): + ), patch("switchbee.api.polling.CentralUnitPolling._login", return_value=None): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { diff --git a/tests/components/system_bridge/test_config_flow.py b/tests/components/system_bridge/test_config_flow.py index 56afc87c3bb..ff517b8963d 100644 --- a/tests/components/system_bridge/test_config_flow.py +++ b/tests/components/system_bridge/test_config_flow.py @@ -3,14 +3,15 @@ import asyncio from ipaddress import ip_address from unittest.mock import patch -from systembridgeconnector.const import MODEL_SYSTEM, TYPE_DATA_UPDATE +from systembridgeconnector.const import TYPE_DATA_UPDATE from systembridgeconnector.exceptions import ( AuthenticationException, ConnectionClosedException, ConnectionErrorException, ) -from systembridgeconnector.models.response import Response -from systembridgeconnector.models.system import LastUpdated, System +from systembridgemodels.const import MODEL_SYSTEM +from systembridgemodels.response import Response +from systembridgemodels.system import LastUpdated, System from homeassistant import config_entries, data_entry_flow from homeassistant.components import zeroconf @@ -151,7 +152,7 @@ async def test_user_flow(hass: HomeAssistant) -> None: "systembridgeconnector.websocket_client.WebSocketClient.get_data", return_value=FIXTURE_DATA_RESPONSE, ), patch( - "systembridgeconnector.websocket_client.WebSocketClient.listen" + "systembridgeconnector.websocket_client.WebSocketClient.listen", ), patch( "homeassistant.components.system_bridge.async_setup_entry", return_value=True, @@ -449,7 +450,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: "systembridgeconnector.websocket_client.WebSocketClient.get_data", return_value=FIXTURE_DATA_RESPONSE, ), patch( - "systembridgeconnector.websocket_client.WebSocketClient.listen" + "systembridgeconnector.websocket_client.WebSocketClient.listen", ), patch( "homeassistant.components.system_bridge.async_setup_entry", return_value=True, @@ -483,7 +484,7 @@ async def test_zeroconf_flow(hass: HomeAssistant) -> None: "systembridgeconnector.websocket_client.WebSocketClient.get_data", return_value=FIXTURE_DATA_RESPONSE, ), patch( - "systembridgeconnector.websocket_client.WebSocketClient.listen" + "systembridgeconnector.websocket_client.WebSocketClient.listen", ), patch( "homeassistant.components.system_bridge.async_setup_entry", return_value=True, diff --git a/tests/components/tag/test_event.py b/tests/components/tag/test_event.py new file mode 100644 index 00000000000..7112a0cda4f --- /dev/null +++ b/tests/components/tag/test_event.py @@ -0,0 +1,106 @@ +"""Tests for the tag component.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.tag import DOMAIN, EVENT_TAG_SCANNED, async_scan_tag +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import async_capture_events +from tests.typing import WebSocketGenerator + +TEST_TAG_ID = "test tag id" +TEST_TAG_NAME = "test tag name" +TEST_DEVICE_ID = "device id" + + +@pytest.fixture +def storage_setup_named_tag( + hass, + hass_storage, +): + """Storage setup for test case of named tags.""" + + async def _storage(items=None): + if items is None: + hass_storage[DOMAIN] = { + "key": DOMAIN, + "version": 1, + "data": {"items": [{"id": TEST_TAG_ID, CONF_NAME: TEST_TAG_NAME}]}, + } + else: + hass_storage[DOMAIN] = items + config = {DOMAIN: {}} + return await async_setup_component(hass, DOMAIN, config) + + return _storage + + +async def test_named_tag_scanned_event( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup_named_tag +) -> None: + """Test scanning named tag triggering event.""" + assert await storage_setup_named_tag() + + await hass_ws_client(hass) + + events = async_capture_events(hass, EVENT_TAG_SCANNED) + + now = dt_util.utcnow() + with patch("homeassistant.util.dt.utcnow", return_value=now): + await async_scan_tag(hass, TEST_TAG_ID, TEST_DEVICE_ID) + + assert len(events) == 1 + + event = events[0] + event_data = event.data + + assert event_data["name"] == TEST_TAG_NAME + assert event_data["device_id"] == TEST_DEVICE_ID + assert event_data["tag_id"] == TEST_TAG_ID + + +@pytest.fixture +def storage_setup_unnamed_tag(hass, hass_storage): + """Storage setup for test case of unnamed tags.""" + + async def _storage(items=None): + if items is None: + hass_storage[DOMAIN] = { + "key": DOMAIN, + "version": 1, + "data": {"items": [{"id": TEST_TAG_ID}]}, + } + else: + hass_storage[DOMAIN] = items + config = {DOMAIN: {}} + return await async_setup_component(hass, DOMAIN, config) + + return _storage + + +async def test_unnamed_tag_scanned_event( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup_unnamed_tag +) -> None: + """Test scanning named tag triggering event.""" + assert await storage_setup_unnamed_tag() + + await hass_ws_client(hass) + + events = async_capture_events(hass, EVENT_TAG_SCANNED) + + now = dt_util.utcnow() + with patch("homeassistant.util.dt.utcnow", return_value=now): + await async_scan_tag(hass, TEST_TAG_ID, TEST_DEVICE_ID) + + assert len(events) == 1 + + event = events[0] + event_data = event.data + + assert event_data["name"] is None + assert event_data["device_id"] == TEST_DEVICE_ID + assert event_data["tag_id"] == TEST_TAG_ID diff --git a/tests/components/tag/test_init.py b/tests/components/tag/test_init.py index 3e034d2b9f2..5d54f31b13a 100644 --- a/tests/components/tag/test_init.py +++ b/tests/components/tag/test_init.py @@ -131,5 +131,5 @@ async def test_tag_id_exists( await client.send_json({"id": 2, "type": f"{DOMAIN}/create", "tag_id": "test tag"}) response = await client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" assert len(changes) == 0 diff --git a/tests/components/template/snapshots/test_weather.ambr b/tests/components/template/snapshots/test_weather.ambr index 72af2ab1637..0ee7f967176 100644 --- a/tests/components/template/snapshots/test_weather.ambr +++ b/tests/components/template/snapshots/test_weather.ambr @@ -1,4 +1,155 @@ # serializer version: 1 +# name: test_forecasts[config0-1-weather-forecast] + dict({ + 'weather.forecast': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-02-17T14:00:00+00:00', + 'temperature': 14.2, + }), + ]), + }), + }) +# --- +# name: test_forecasts[config0-1-weather-forecast].1 + dict({ + 'weather.forecast': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-02-17T14:00:00+00:00', + 'temperature': 14.2, + }), + ]), + }), + }) +# --- +# name: test_forecasts[config0-1-weather-forecast].2 + dict({ + 'weather.forecast': dict({ + 'forecast': list([ + dict({ + 'condition': 'fog', + 'datetime': '2023-02-17T14:00:00+00:00', + 'is_daytime': True, + 'temperature': 14.2, + }), + ]), + }), + }) +# --- +# name: test_forecasts[config0-1-weather-forecast].3 + dict({ + 'weather.forecast': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-02-17T14:00:00+00:00', + 'temperature': 16.9, + }), + ]), + }), + }) +# --- +# name: test_forecasts[config0-1-weather-get_forecast] + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-02-17T14:00:00+00:00', + 'temperature': 14.2, + }), + ]), + }) +# --- +# name: test_forecasts[config0-1-weather-get_forecast].1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-02-17T14:00:00+00:00', + 'temperature': 14.2, + }), + ]), + }) +# --- +# name: test_forecasts[config0-1-weather-get_forecast].2 + dict({ + 'forecast': list([ + dict({ + 'condition': 'fog', + 'datetime': '2023-02-17T14:00:00+00:00', + 'is_daytime': True, + 'temperature': 14.2, + }), + ]), + }) +# --- +# name: test_forecasts[config0-1-weather-get_forecast].3 + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-02-17T14:00:00+00:00', + 'temperature': 16.9, + }), + ]), + }) +# --- +# name: test_forecasts[config0-1-weather-get_forecasts] + dict({ + 'weather.forecast': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-02-17T14:00:00+00:00', + 'temperature': 14.2, + }), + ]), + }), + }) +# --- +# name: test_forecasts[config0-1-weather-get_forecasts].1 + dict({ + 'weather.forecast': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-02-17T14:00:00+00:00', + 'temperature': 14.2, + }), + ]), + }), + }) +# --- +# name: test_forecasts[config0-1-weather-get_forecasts].2 + dict({ + 'weather.forecast': dict({ + 'forecast': list([ + dict({ + 'condition': 'fog', + 'datetime': '2023-02-17T14:00:00+00:00', + 'is_daytime': True, + 'temperature': 14.2, + }), + ]), + }), + }) +# --- +# name: test_forecasts[config0-1-weather-get_forecasts].3 + dict({ + 'weather.forecast': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-02-17T14:00:00+00:00', + 'temperature': 16.9, + }), + ]), + }), + }) +# --- # name: test_forecasts[config0-1-weather] dict({ 'forecast': list([ @@ -59,6 +210,138 @@ 'last_wind_speed': None, }) # --- +# name: test_trigger_weather_services[config0-1-template-forecast] + dict({ + 'weather.test': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2023-10-19T06:50:05-07:00', + 'precipitation': 20.0, + 'temperature': 20.0, + 'templow': 15.0, + }), + ]), + }), + }) +# --- +# name: test_trigger_weather_services[config0-1-template-forecast].1 + dict({ + 'weather.test': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2023-10-19T06:50:05-07:00', + 'precipitation': 20.0, + 'temperature': 20.0, + 'templow': 15.0, + }), + ]), + }), + }) +# --- +# name: test_trigger_weather_services[config0-1-template-forecast].2 + dict({ + 'weather.test': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2023-10-19T06:50:05-07:00', + 'is_daytime': True, + 'precipitation': 20.0, + 'temperature': 20.0, + 'templow': 15.0, + }), + ]), + }), + }) +# --- +# name: test_trigger_weather_services[config0-1-template-get_forecast] + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2023-10-19T06:50:05-07:00', + 'precipitation': 20.0, + 'temperature': 20.0, + 'templow': 15.0, + }), + ]), + }) +# --- +# name: test_trigger_weather_services[config0-1-template-get_forecast].1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2023-10-19T06:50:05-07:00', + 'precipitation': 20.0, + 'temperature': 20.0, + 'templow': 15.0, + }), + ]), + }) +# --- +# name: test_trigger_weather_services[config0-1-template-get_forecast].2 + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2023-10-19T06:50:05-07:00', + 'is_daytime': True, + 'precipitation': 20.0, + 'temperature': 20.0, + 'templow': 15.0, + }), + ]), + }) +# --- +# name: test_trigger_weather_services[config0-1-template-get_forecasts] + dict({ + 'weather.test': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2023-10-19T06:50:05-07:00', + 'precipitation': 20.0, + 'temperature': 20.0, + 'templow': 15.0, + }), + ]), + }), + }) +# --- +# name: test_trigger_weather_services[config0-1-template-get_forecasts].1 + dict({ + 'weather.test': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2023-10-19T06:50:05-07:00', + 'precipitation': 20.0, + 'temperature': 20.0, + 'templow': 15.0, + }), + ]), + }), + }) +# --- +# name: test_trigger_weather_services[config0-1-template-get_forecasts].2 + dict({ + 'weather.test': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2023-10-19T06:50:05-07:00', + 'is_daytime': True, + 'precipitation': 20.0, + 'temperature': 20.0, + 'templow': 15.0, + }), + ]), + }), + }) +# --- # name: test_trigger_weather_services[config0-1-template] dict({ 'forecast': list([ diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index dd4fa1d32a5..ef2390680b6 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -198,13 +198,13 @@ async def test_optimistic_states(hass: HomeAssistant, start_ha) -> None: "wibble": {"test_panel": "Invalid"}, } }, - "[wibble] is an invalid option", + "'wibble' is an invalid option", ), ( { "alarm_control_panel": {"platform": "template"}, }, - "required key not provided @ data['panels']", + "required key 'panels' not provided", ), ( { diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index f4cfe90b9f0..b95a68afd85 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -845,4 +845,4 @@ async def test_option_flow_sensor_preview_config_entry_removed( ) msg = await client.receive_json() assert not msg["success"] - assert msg["error"] == {"code": "unknown_error", "message": "Unknown error"} + assert msg["error"] == {"code": "home_assistant_error", "message": "Unknown error"} diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index fefad59aa08..35f03ee9508 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -424,7 +424,7 @@ async def test_template_open_or_position( ) -> None: """Test that at least one of open_cover or set_position is used.""" assert hass.states.async_all("cover") == [] - assert "Invalid config for [cover.template]" in caplog_setup_text + assert "Invalid config for 'cover.template'" in caplog_setup_text @pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index f9b0bddddcf..ccdafebd8bb 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -12,6 +12,7 @@ from homeassistant.components.fan import ( DIRECTION_REVERSE, DOMAIN, FanEntityFeature, + NotValidPresetModeError, ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -489,7 +490,11 @@ async def test_preset_modes(hass: HomeAssistant, calls) -> None: ("smart", "smart", 3), ("invalid", "smart", 3), ]: - await common.async_set_preset_mode(hass, _TEST_FAN, extra) + if extra != state: + with pytest.raises(NotValidPresetModeError): + await common.async_set_preset_mode(hass, _TEST_FAN, extra) + else: + await common.async_set_preset_mode(hass, _TEST_FAN, extra) assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == state assert len(calls) == expected_calls assert calls[-1].data["action"] == "set_preset_mode" @@ -550,6 +555,7 @@ async def test_no_value_template(hass: HomeAssistant, calls) -> None: with assert_setup_component(1, "fan"): test_fan_config = { "preset_mode_template": "{{ states('input_select.preset_mode') }}", + "preset_modes": ["auto"], "percentage_template": "{{ states('input_number.percentage') }}", "oscillating_template": "{{ states('input_select.osc') }}", "direction_template": "{{ states('input_select.direction') }}", @@ -625,18 +631,18 @@ async def test_no_value_template(hass: HomeAssistant, calls) -> None: await hass.async_block_till_done() await common.async_turn_on(hass, _TEST_FAN) - _verify(hass, STATE_ON, 0, None, None, None) + _verify(hass, STATE_ON, 0, None, None, "auto") await common.async_turn_off(hass, _TEST_FAN) - _verify(hass, STATE_OFF, 0, None, None, None) + _verify(hass, STATE_OFF, 0, None, None, "auto") percent = 100 await common.async_set_percentage(hass, _TEST_FAN, percent) assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == percent - _verify(hass, STATE_ON, percent, None, None, None) + _verify(hass, STATE_ON, percent, None, None, "auto") await common.async_turn_off(hass, _TEST_FAN) - _verify(hass, STATE_OFF, percent, None, None, None) + _verify(hass, STATE_OFF, percent, None, None, "auto") preset = "auto" await common.async_set_preset_mode(hass, _TEST_FAN, preset) diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index f807b185c45..ec830d4daf6 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -7,6 +7,9 @@ from homeassistant.components.light import ( ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + ATTR_RGBWW_COLOR, ATTR_TRANSITION, ColorMode, LightEntityFeature, @@ -72,7 +75,7 @@ OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG = { } -OPTIMISTIC_HS_COLOR_LIGHT_CONFIG = { +OPTIMISTIC_LEGACY_COLOR_LIGHT_CONFIG = { **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, "set_color": { "service": "test.automation", @@ -86,6 +89,68 @@ OPTIMISTIC_HS_COLOR_LIGHT_CONFIG = { } +OPTIMISTIC_HS_COLOR_LIGHT_CONFIG = { + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "set_hs": { + "service": "test.automation", + "data_template": { + "action": "set_hs", + "caller": "{{ this.entity_id }}", + "s": "{{s}}", + "h": "{{h}}", + }, + }, +} + + +OPTIMISTIC_RGB_COLOR_LIGHT_CONFIG = { + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "set_rgb": { + "service": "test.automation", + "data_template": { + "action": "set_rgb", + "caller": "{{ this.entity_id }}", + "r": "{{r}}", + "g": "{{g}}", + "b": "{{b}}", + }, + }, +} + + +OPTIMISTIC_RGBW_COLOR_LIGHT_CONFIG = { + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "set_rgbw": { + "service": "test.automation", + "data_template": { + "action": "set_rgbw", + "caller": "{{ this.entity_id }}", + "r": "{{r}}", + "g": "{{g}}", + "b": "{{b}}", + "w": "{{w}}", + }, + }, +} + + +OPTIMISTIC_RGBWW_COLOR_LIGHT_CONFIG = { + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "set_rgbww": { + "service": "test.automation", + "data_template": { + "action": "set_rgbww", + "caller": "{{ this.entity_id }}", + "r": "{{r}}", + "g": "{{g}}", + "b": "{{b}}", + "cw": "{{cw}}", + "ww": "{{ww}}", + }, + }, +} + + async def async_setup_light(hass, count, light_config): """Do setup of light integration.""" config = {"light": {"platform": "template", "lights": light_config}} @@ -607,6 +672,7 @@ async def test_level_action_no_template( "{{ state_attr('light.nolight', 'brightness') }}", ColorMode.BRIGHTNESS, ), + (None, "{{'one'}}", ColorMode.BRIGHTNESS), ], ) async def test_level_template( @@ -643,6 +709,7 @@ async def test_level_template( (None, "None", ColorMode.COLOR_TEMP), (None, "{{ none }}", ColorMode.COLOR_TEMP), (None, "", ColorMode.COLOR_TEMP), + (None, "{{ 'one' }}", ColorMode.COLOR_TEMP), ], ) async def test_temperature_template( @@ -797,17 +864,17 @@ async def test_entity_picture_template(hass: HomeAssistant, setup_light) -> None [ { "test_template_light": { - **OPTIMISTIC_HS_COLOR_LIGHT_CONFIG, + **OPTIMISTIC_LEGACY_COLOR_LIGHT_CONFIG, "value_template": "{{1 == 1}}", } }, ], ) -async def test_color_action_no_template( - hass: HomeAssistant, +async def test_legacy_color_action_no_template( + hass, setup_light, calls, -) -> None: +): """Test setting color with optimistic template.""" state = hass.states.get("light.test_template_light") assert state.attributes.get("hs_color") is None @@ -833,6 +900,186 @@ async def test_color_action_no_template( assert state.attributes["supported_features"] == 0 +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + "light_config", + [ + { + "test_template_light": { + **OPTIMISTIC_HS_COLOR_LIGHT_CONFIG, + "value_template": "{{1 == 1}}", + } + }, + ], +) +async def test_hs_color_action_no_template( + hass: HomeAssistant, + setup_light, + calls, +) -> None: + """Test setting hs color with optimistic template.""" + state = hass.states.get("light.test_template_light") + assert state.attributes.get("hs_color") is None + + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_template_light", ATTR_HS_COLOR: (40, 50)}, + blocking=True, + ) + + assert len(calls) == 1 + assert calls[-1].data["action"] == "set_hs" + assert calls[-1].data["caller"] == "light.test_template_light" + assert calls[-1].data["h"] == 40 + assert calls[-1].data["s"] == 50 + + state = hass.states.get("light.test_template_light") + assert state.state == STATE_ON + assert state.attributes["color_mode"] == ColorMode.HS + assert state.attributes.get("hs_color") == (40, 50) + assert state.attributes["supported_color_modes"] == [ColorMode.HS] + assert state.attributes["supported_features"] == 0 + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + "light_config", + [ + { + "test_template_light": { + **OPTIMISTIC_RGB_COLOR_LIGHT_CONFIG, + "value_template": "{{1 == 1}}", + } + }, + ], +) +async def test_rgb_color_action_no_template( + hass: HomeAssistant, + setup_light, + calls, +) -> None: + """Test setting rgb color with optimistic template.""" + state = hass.states.get("light.test_template_light") + assert state.attributes.get("rgb_color") is None + + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_template_light", ATTR_RGB_COLOR: (160, 78, 192)}, + blocking=True, + ) + + assert len(calls) == 1 + assert calls[-1].data["action"] == "set_rgb" + assert calls[-1].data["caller"] == "light.test_template_light" + assert calls[-1].data["r"] == 160 + assert calls[-1].data["g"] == 78 + assert calls[-1].data["b"] == 192 + + state = hass.states.get("light.test_template_light") + assert state.state == STATE_ON + assert state.attributes["color_mode"] == ColorMode.RGB + assert state.attributes.get("rgb_color") == (160, 78, 192) + assert state.attributes["supported_color_modes"] == [ColorMode.RGB] + assert state.attributes["supported_features"] == 0 + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + "light_config", + [ + { + "test_template_light": { + **OPTIMISTIC_RGBW_COLOR_LIGHT_CONFIG, + "value_template": "{{1 == 1}}", + } + }, + ], +) +async def test_rgbw_color_action_no_template( + hass: HomeAssistant, + setup_light, + calls, +) -> None: + """Test setting rgbw color with optimistic template.""" + state = hass.states.get("light.test_template_light") + assert state.attributes.get("rgbw_color") is None + + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.test_template_light", + ATTR_RGBW_COLOR: (160, 78, 192, 25), + }, + blocking=True, + ) + + assert len(calls) == 1 + assert calls[-1].data["action"] == "set_rgbw" + assert calls[-1].data["caller"] == "light.test_template_light" + assert calls[-1].data["r"] == 160 + assert calls[-1].data["g"] == 78 + assert calls[-1].data["b"] == 192 + assert calls[-1].data["w"] == 25 + + state = hass.states.get("light.test_template_light") + assert state.state == STATE_ON + assert state.attributes["color_mode"] == ColorMode.RGBW + assert state.attributes.get("rgbw_color") == (160, 78, 192, 25) + assert state.attributes["supported_color_modes"] == [ColorMode.RGBW] + assert state.attributes["supported_features"] == 0 + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + "light_config", + [ + { + "test_template_light": { + **OPTIMISTIC_RGBWW_COLOR_LIGHT_CONFIG, + "value_template": "{{1 == 1}}", + } + }, + ], +) +async def test_rgbww_color_action_no_template( + hass: HomeAssistant, + setup_light, + calls, +) -> None: + """Test setting rgbww color with optimistic template.""" + state = hass.states.get("light.test_template_light") + assert state.attributes.get("rgbww_color") is None + + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.test_template_light", + ATTR_RGBWW_COLOR: (160, 78, 192, 25, 55), + }, + blocking=True, + ) + + assert len(calls) == 1 + assert calls[-1].data["action"] == "set_rgbww" + assert calls[-1].data["caller"] == "light.test_template_light" + assert calls[-1].data["r"] == 160 + assert calls[-1].data["g"] == 78 + assert calls[-1].data["b"] == 192 + assert calls[-1].data["cw"] == 25 + assert calls[-1].data["ww"] == 55 + + state = hass.states.get("light.test_template_light") + assert state.state == STATE_ON + assert state.attributes["color_mode"] == ColorMode.RGBWW + assert state.attributes.get("rgbww_color") == (160, 78, 192, 25, 55) + assert state.attributes["supported_color_modes"] == [ColorMode.RGBWW] + assert state.attributes["supported_features"] == 0 + + @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( ("expected_hs", "color_template", "expected_color_mode"), @@ -845,19 +1092,20 @@ async def test_color_action_no_template( (None, "{{x - 12}}", ColorMode.HS), (None, "", ColorMode.HS), (None, "{{ none }}", ColorMode.HS), + (None, "{{('one','two')}}", ColorMode.HS), ], ) -async def test_color_template( - hass: HomeAssistant, +async def test_legacy_color_template( + hass, expected_hs, expected_color_mode, count, color_template, -) -> None: +): """Test the template for the color.""" light_config = { "test_template_light": { - **OPTIMISTIC_HS_COLOR_LIGHT_CONFIG, + **OPTIMISTIC_LEGACY_COLOR_LIGHT_CONFIG, "value_template": "{{ 1 == 1 }}", "color_template": color_template, } @@ -871,6 +1119,176 @@ async def test_color_template( assert state.attributes["supported_features"] == 0 +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("expected_hs", "hs_template", "expected_color_mode"), + [ + ((360, 100), "{{(360, 100)}}", ColorMode.HS), + ((360, 100), "(360, 100)", ColorMode.HS), + ((359.9, 99.9), "{{(359.9, 99.9)}}", ColorMode.HS), + (None, "{{(361, 100)}}", ColorMode.HS), + (None, "{{(360, 101)}}", ColorMode.HS), + (None, "[{{(360)}},{{null}}]", ColorMode.HS), + (None, "{{x - 12}}", ColorMode.HS), + (None, "", ColorMode.HS), + (None, "{{ none }}", ColorMode.HS), + (None, "{{('one','two')}}", ColorMode.HS), + ], +) +async def test_hs_template( + hass: HomeAssistant, + expected_hs, + expected_color_mode, + count, + hs_template, +) -> None: + """Test the template for the color.""" + light_config = { + "test_template_light": { + **OPTIMISTIC_HS_COLOR_LIGHT_CONFIG, + "value_template": "{{ 1 == 1 }}", + "hs_template": hs_template, + } + } + await async_setup_light(hass, count, light_config) + state = hass.states.get("light.test_template_light") + assert state.attributes.get("hs_color") == expected_hs + assert state.state == STATE_ON + assert state.attributes["color_mode"] == expected_color_mode + assert state.attributes["supported_color_modes"] == [ColorMode.HS] + assert state.attributes["supported_features"] == 0 + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("expected_rgb", "rgb_template", "expected_color_mode"), + [ + ((160, 78, 192), "{{(160, 78, 192)}}", ColorMode.RGB), + ((160, 78, 192), "{{[160, 78, 192]}}", ColorMode.RGB), + ((160, 78, 192), "(160, 78, 192)", ColorMode.RGB), + ((159, 77, 191), "{{(159.9, 77.9, 191.9)}}", ColorMode.RGB), + (None, "{{(256, 100, 100)}}", ColorMode.RGB), + (None, "{{(100, 256, 100)}}", ColorMode.RGB), + (None, "{{(100, 100, 256)}}", ColorMode.RGB), + (None, "{{x - 12}}", ColorMode.RGB), + (None, "", ColorMode.RGB), + (None, "{{ none }}", ColorMode.RGB), + (None, "{{('one','two','tree')}}", ColorMode.RGB), + ], +) +async def test_rgb_template( + hass: HomeAssistant, + expected_rgb, + expected_color_mode, + count, + rgb_template, +) -> None: + """Test the template for the color.""" + light_config = { + "test_template_light": { + **OPTIMISTIC_RGB_COLOR_LIGHT_CONFIG, + "value_template": "{{ 1 == 1 }}", + "rgb_template": rgb_template, + } + } + await async_setup_light(hass, count, light_config) + state = hass.states.get("light.test_template_light") + assert state.attributes.get("rgb_color") == expected_rgb + assert state.state == STATE_ON + assert state.attributes["color_mode"] == expected_color_mode + assert state.attributes["supported_color_modes"] == [ColorMode.RGB] + assert state.attributes["supported_features"] == 0 + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("expected_rgbw", "rgbw_template", "expected_color_mode"), + [ + ((160, 78, 192, 25), "{{(160, 78, 192, 25)}}", ColorMode.RGBW), + ((160, 78, 192, 25), "{{[160, 78, 192, 25]}}", ColorMode.RGBW), + ((160, 78, 192, 25), "(160, 78, 192, 25)", ColorMode.RGBW), + ((159, 77, 191, 24), "{{(159.9, 77.9, 191.9, 24.9)}}", ColorMode.RGBW), + (None, "{{(256, 100, 100, 100)}}", ColorMode.RGBW), + (None, "{{(100, 256, 100, 100)}}", ColorMode.RGBW), + (None, "{{(100, 100, 256, 100)}}", ColorMode.RGBW), + (None, "{{(100, 100, 100, 256)}}", ColorMode.RGBW), + (None, "{{x - 12}}", ColorMode.RGBW), + (None, "", ColorMode.RGBW), + (None, "{{ none }}", ColorMode.RGBW), + (None, "{{('one','two','tree','four')}}", ColorMode.RGBW), + ], +) +async def test_rgbw_template( + hass: HomeAssistant, + expected_rgbw, + expected_color_mode, + count, + rgbw_template, +) -> None: + """Test the template for the color.""" + light_config = { + "test_template_light": { + **OPTIMISTIC_RGBW_COLOR_LIGHT_CONFIG, + "value_template": "{{ 1 == 1 }}", + "rgbw_template": rgbw_template, + } + } + await async_setup_light(hass, count, light_config) + state = hass.states.get("light.test_template_light") + assert state.attributes.get("rgbw_color") == expected_rgbw + assert state.state == STATE_ON + assert state.attributes["color_mode"] == expected_color_mode + assert state.attributes["supported_color_modes"] == [ColorMode.RGBW] + assert state.attributes["supported_features"] == 0 + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("expected_rgbww", "rgbww_template", "expected_color_mode"), + [ + ((160, 78, 192, 25, 55), "{{(160, 78, 192, 25, 55)}}", ColorMode.RGBWW), + ((160, 78, 192, 25, 55), "(160, 78, 192, 25, 55)", ColorMode.RGBWW), + ((160, 78, 192, 25, 55), "{{[160, 78, 192, 25, 55]}}", ColorMode.RGBWW), + ( + (159, 77, 191, 24, 54), + "{{(159.9, 77.9, 191.9, 24.9, 54.9)}}", + ColorMode.RGBWW, + ), + (None, "{{(256, 100, 100, 100, 100)}}", ColorMode.RGBWW), + (None, "{{(100, 256, 100, 100, 100)}}", ColorMode.RGBWW), + (None, "{{(100, 100, 256, 100, 100)}}", ColorMode.RGBWW), + (None, "{{(100, 100, 100, 256, 100)}}", ColorMode.RGBWW), + (None, "{{(100, 100, 100, 100, 256)}}", ColorMode.RGBWW), + (None, "{{x - 12}}", ColorMode.RGBWW), + (None, "", ColorMode.RGBWW), + (None, "{{ none }}", ColorMode.RGBWW), + (None, "{{('one','two','tree','four','five')}}", ColorMode.RGBWW), + ], +) +async def test_rgbww_template( + hass: HomeAssistant, + expected_rgbww, + expected_color_mode, + count, + rgbww_template, +) -> None: + """Test the template for the color.""" + light_config = { + "test_template_light": { + **OPTIMISTIC_RGBWW_COLOR_LIGHT_CONFIG, + "value_template": "{{ 1 == 1 }}", + "rgbww_template": rgbww_template, + } + } + await async_setup_light(hass, count, light_config) + state = hass.states.get("light.test_template_light") + assert state.attributes.get("rgbww_color") == expected_rgbww + assert state.state == STATE_ON + assert state.attributes["color_mode"] == expected_color_mode + assert state.attributes["supported_color_modes"] == [ColorMode.RGBWW] + assert state.attributes["supported_features"] == 0 + + @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( "light_config", @@ -879,16 +1297,14 @@ async def test_color_template( "test_template_light": { **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, "value_template": "{{1 == 1}}", - "set_color": [ - { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "h": "{{h}}", - "s": "{{s}}", - }, + "set_hs": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "h": "{{h}}", + "s": "{{s}}", }, - ], + }, "set_temperature": { "service": "test.automation", "data_template": { @@ -896,18 +1312,48 @@ async def test_color_template( "color_temp": "{{color_temp}}", }, }, + "set_rgb": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "r": "{{r}}", + "g": "{{g}}", + "b": "{{b}}", + }, + }, + "set_rgbw": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "r": "{{r}}", + "g": "{{g}}", + "b": "{{b}}", + "w": "{{w}}", + }, + }, + "set_rgbww": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "r": "{{r}}", + "g": "{{g}}", + "b": "{{b}}", + "cw": "{{cw}}", + "ww": "{{ww}}", + }, + }, } }, ], ) -async def test_color_and_temperature_actions_no_template( +async def test_all_colors_mode_no_template( hass: HomeAssistant, setup_light, calls ) -> None: """Test setting color and color temperature with optimistic template.""" state = hass.states.get("light.test_template_light") assert state.attributes.get("hs_color") is None - # Optimistically set color, light should be in hs_color mode + # Optimistically set hs color, light should be in hs_color mode await hass.services.async_call( light.DOMAIN, SERVICE_TURN_ON, @@ -926,6 +1372,9 @@ async def test_color_and_temperature_actions_no_template( assert state.attributes["supported_color_modes"] == [ ColorMode.COLOR_TEMP, ColorMode.HS, + ColorMode.RGB, + ColorMode.RGBW, + ColorMode.RGBWW, ] assert state.attributes["supported_features"] == 0 @@ -947,10 +1396,100 @@ async def test_color_and_temperature_actions_no_template( assert state.attributes["supported_color_modes"] == [ ColorMode.COLOR_TEMP, ColorMode.HS, + ColorMode.RGB, + ColorMode.RGBW, + ColorMode.RGBWW, ] assert state.attributes["supported_features"] == 0 - # Optimistically set color, light should again be in hs_color mode + # Optimistically set rgb color, light should be in rgb_color mode + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_template_light", ATTR_RGB_COLOR: (160, 78, 192)}, + blocking=True, + ) + + assert len(calls) == 3 + assert calls[-1].data["r"] == 160 + assert calls[-1].data["g"] == 78 + assert calls[-1].data["b"] == 192 + + state = hass.states.get("light.test_template_light") + assert state.attributes["color_mode"] == ColorMode.RGB + assert state.attributes["color_temp"] is None + assert state.attributes["rgb_color"] == (160, 78, 192) + assert state.attributes["supported_color_modes"] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ColorMode.RGB, + ColorMode.RGBW, + ColorMode.RGBWW, + ] + assert state.attributes["supported_features"] == 0 + + # Optimistically set rgbw color, light should be in rgb_color mode + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.test_template_light", + ATTR_RGBW_COLOR: (160, 78, 192, 25), + }, + blocking=True, + ) + + assert len(calls) == 4 + assert calls[-1].data["r"] == 160 + assert calls[-1].data["g"] == 78 + assert calls[-1].data["b"] == 192 + assert calls[-1].data["w"] == 25 + + state = hass.states.get("light.test_template_light") + assert state.attributes["color_mode"] == ColorMode.RGBW + assert state.attributes["color_temp"] is None + assert state.attributes["rgbw_color"] == (160, 78, 192, 25) + assert state.attributes["supported_color_modes"] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ColorMode.RGB, + ColorMode.RGBW, + ColorMode.RGBWW, + ] + assert state.attributes["supported_features"] == 0 + + # Optimistically set rgbww color, light should be in rgb_color mode + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.test_template_light", + ATTR_RGBWW_COLOR: (160, 78, 192, 25, 55), + }, + blocking=True, + ) + + assert len(calls) == 5 + assert calls[-1].data["r"] == 160 + assert calls[-1].data["g"] == 78 + assert calls[-1].data["b"] == 192 + assert calls[-1].data["cw"] == 25 + assert calls[-1].data["ww"] == 55 + + state = hass.states.get("light.test_template_light") + assert state.attributes["color_mode"] == ColorMode.RGBWW + assert state.attributes["color_temp"] is None + assert state.attributes["rgbww_color"] == (160, 78, 192, 25, 55) + assert state.attributes["supported_color_modes"] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ColorMode.RGB, + ColorMode.RGBW, + ColorMode.RGBWW, + ] + assert state.attributes["supported_features"] == 0 + + # Optimistically set hs color, light should again be in hs_color mode await hass.services.async_call( light.DOMAIN, SERVICE_TURN_ON, @@ -958,7 +1497,7 @@ async def test_color_and_temperature_actions_no_template( blocking=True, ) - assert len(calls) == 3 + assert len(calls) == 6 assert calls[-1].data["h"] == 10 assert calls[-1].data["s"] == 20 @@ -969,6 +1508,9 @@ async def test_color_and_temperature_actions_no_template( assert state.attributes["supported_color_modes"] == [ ColorMode.COLOR_TEMP, ColorMode.HS, + ColorMode.RGB, + ColorMode.RGBW, + ColorMode.RGBWW, ] assert state.attributes["supported_features"] == 0 @@ -980,7 +1522,7 @@ async def test_color_and_temperature_actions_no_template( blocking=True, ) - assert len(calls) == 4 + assert len(calls) == 7 assert calls[-1].data["color_temp"] == 234 state = hass.states.get("light.test_template_light") @@ -990,6 +1532,9 @@ async def test_color_and_temperature_actions_no_template( assert state.attributes["supported_color_modes"] == [ ColorMode.COLOR_TEMP, ColorMode.HS, + ColorMode.RGB, + ColorMode.RGBW, + ColorMode.RGBWW, ] assert state.attributes["supported_features"] == 0 diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 524f9c41aeb..36071c746da 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -18,7 +18,8 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, DOMAIN as WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + LEGACY_SERVICE_GET_FORECAST, + SERVICE_GET_FORECASTS, Forecast, ) from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNAVAILABLE, STATE_UNKNOWN @@ -92,6 +93,13 @@ async def test_template_state_text(hass: HomeAssistant, start_ha) -> None: assert state.attributes.get(v_attr) == value +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) @pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @pytest.mark.parametrize( "config", @@ -114,7 +122,7 @@ async def test_template_state_text(hass: HomeAssistant, start_ha) -> None: ], ) async def test_forecasts( - hass: HomeAssistant, start_ha, snapshot: SnapshotAssertion + hass: HomeAssistant, start_ha, snapshot: SnapshotAssertion, service: str ) -> None: """Test forecast service.""" for attr, _v_attr, value in [ @@ -161,7 +169,7 @@ async def test_forecasts( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, {"entity_id": "weather.forecast", "type": "daily"}, blocking=True, return_response=True, @@ -169,7 +177,7 @@ async def test_forecasts( assert response == snapshot response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, {"entity_id": "weather.forecast", "type": "hourly"}, blocking=True, return_response=True, @@ -177,7 +185,7 @@ async def test_forecasts( assert response == snapshot response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, {"entity_id": "weather.forecast", "type": "twice_daily"}, blocking=True, return_response=True, @@ -204,7 +212,7 @@ async def test_forecasts( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, {"entity_id": "weather.forecast", "type": "daily"}, blocking=True, return_response=True, @@ -212,6 +220,13 @@ async def test_forecasts( assert response == snapshot +@pytest.mark.parametrize( + ("service", "expected"), + [ + (SERVICE_GET_FORECASTS, {"weather.forecast": {"forecast": []}}), + (LEGACY_SERVICE_GET_FORECAST, {"forecast": []}), + ], +) @pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @pytest.mark.parametrize( "config", @@ -236,6 +251,8 @@ async def test_forecast_invalid( hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture, + service: str, + expected: dict[str, Any], ) -> None: """Test invalid forecasts.""" for attr, _v_attr, value in [ @@ -271,23 +288,30 @@ async def test_forecast_invalid( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, {"entity_id": "weather.forecast", "type": "daily"}, blocking=True, return_response=True, ) - assert response == {"forecast": []} + assert response == expected response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, {"entity_id": "weather.forecast", "type": "hourly"}, blocking=True, return_response=True, ) - assert response == {"forecast": []} + assert response == expected assert "Only valid keys in Forecast are allowed" in caplog.text +@pytest.mark.parametrize( + ("service", "expected"), + [ + (SERVICE_GET_FORECASTS, {"weather.forecast": {"forecast": []}}), + (LEGACY_SERVICE_GET_FORECAST, {"forecast": []}), + ], +) @pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @pytest.mark.parametrize( "config", @@ -311,6 +335,8 @@ async def test_forecast_invalid_is_daytime_missing_in_twice_daily( hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture, + service: str, + expected: dict[str, Any], ) -> None: """Test forecast service invalid when is_daytime missing in twice_daily forecast.""" for attr, _v_attr, value in [ @@ -340,15 +366,22 @@ async def test_forecast_invalid_is_daytime_missing_in_twice_daily( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, {"entity_id": "weather.forecast", "type": "twice_daily"}, blocking=True, return_response=True, ) - assert response == {"forecast": []} + assert response == expected assert "`is_daytime` is missing in twice_daily forecast" in caplog.text +@pytest.mark.parametrize( + ("service", "expected"), + [ + (SERVICE_GET_FORECASTS, {"weather.forecast": {"forecast": []}}), + (LEGACY_SERVICE_GET_FORECAST, {"forecast": []}), + ], +) @pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @pytest.mark.parametrize( "config", @@ -372,6 +405,8 @@ async def test_forecast_invalid_datetime_missing( hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture, + service: str, + expected: dict[str, Any], ) -> None: """Test forecast service invalid when datetime missing.""" for attr, _v_attr, value in [ @@ -401,15 +436,22 @@ async def test_forecast_invalid_datetime_missing( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, {"entity_id": "weather.forecast", "type": "twice_daily"}, blocking=True, return_response=True, ) - assert response == {"forecast": []} + assert response == expected assert "`datetime` is required in forecasts" in caplog.text +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) @pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @pytest.mark.parametrize( "config", @@ -431,7 +473,7 @@ async def test_forecast_invalid_datetime_missing( ], ) async def test_forecast_format_error( - hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture, service: str ) -> None: """Test forecast service invalid on incorrect format.""" for attr, _v_attr, value in [ @@ -467,7 +509,7 @@ async def test_forecast_format_error( await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, {"entity_id": "weather.forecast", "type": "daily"}, blocking=True, return_response=True, @@ -475,7 +517,7 @@ async def test_forecast_format_error( assert "Forecasts is not a list, see Weather documentation" in caplog.text await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, {"entity_id": "weather.forecast", "type": "hourly"}, blocking=True, return_response=True, @@ -638,6 +680,13 @@ async def test_trigger_action( assert state.context is context +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) @pytest.mark.parametrize(("count", "domain"), [(1, "template")]) @pytest.mark.parametrize( "config", @@ -694,6 +743,7 @@ async def test_trigger_weather_services( start_ha, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + service: str, ) -> None: """Test trigger weather entity with services.""" state = hass.states.get("weather.test") @@ -756,7 +806,7 @@ async def test_trigger_weather_services( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": state.entity_id, "type": "daily", @@ -768,7 +818,7 @@ async def test_trigger_weather_services( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": state.entity_id, "type": "hourly", @@ -780,7 +830,7 @@ async def test_trigger_weather_services( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": state.entity_id, "type": "twice_daily", diff --git a/tests/components/tod/test_binary_sensor.py b/tests/components/tod/test_binary_sensor.py index c1823c23f8b..c7979b884d4 100644 --- a/tests/components/tod/test_binary_sensor.py +++ b/tests/components/tod/test_binary_sensor.py @@ -614,21 +614,62 @@ async def test_sun_offset( assert state.state == STATE_ON -async def test_dst( +async def test_dst1( hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info ) -> None: - """Test sun event with offset.""" + """Test DST when time falls in non-existent hour. Also check 48 hours later.""" hass.config.time_zone = "CET" dt_util.set_default_time_zone(dt_util.get_time_zone("CET")) - test_time = datetime(2019, 3, 30, 3, 0, 0, tzinfo=hass_tz_info) + test_time1 = datetime(2019, 3, 30, 3, 0, 0, tzinfo=dt_util.get_time_zone("CET")) + test_time2 = datetime(2019, 3, 31, 3, 0, 0, tzinfo=dt_util.get_time_zone("CET")) config = { "binary_sensor": [ {"platform": "tod", "name": "Day", "after": "2:30", "before": "2:40"} ] } - # Test DST: + # Test DST #1: # after 2019-03-30 03:00 CET the next update should ge scheduled - # at 3:30 not 2:30 local time + # at 2:30am, but on 2019-03-31, that hour does not exist. That means + # the start/end will end up happning on the next available second (3am) + # Essentially, the ToD sensor never turns on that day. + entity_id = "binary_sensor.day" + freezer.move_to(test_time1) + await async_setup_component(hass, "binary_sensor", config) + await hass.async_block_till_done() + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes["after"] == "2019-03-31T03:00:00+02:00" + assert state.attributes["before"] == "2019-03-31T03:00:00+02:00" + assert state.attributes["next_update"] == "2019-03-31T03:00:00+02:00" + assert state.state == STATE_OFF + + # But the following day, the sensor should resume it normal operation. + freezer.move_to(test_time2) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes["after"] == "2019-04-01T02:30:00+02:00" + assert state.attributes["before"] == "2019-04-01T02:40:00+02:00" + assert state.attributes["next_update"] == "2019-04-01T02:30:00+02:00" + + assert state.state == STATE_OFF + + +async def test_dst2(hass, freezer, hass_tz_info): + """Test DST when there's a time switch in the East.""" + hass.config.time_zone = "CET" + dt_util.set_default_time_zone(dt_util.get_time_zone("CET")) + test_time = datetime(2019, 3, 30, 5, 0, 0, tzinfo=dt_util.get_time_zone("CET")) + config = { + "binary_sensor": [ + {"platform": "tod", "name": "Day", "after": "4:30", "before": "4:40"} + ] + } + # Test DST #2: + # after 2019-03-30 05:00 CET the next update should ge scheduled + # at 4:30+02 not 4:30+01 entity_id = "binary_sensor.day" freezer.move_to(test_time) await async_setup_component(hass, "binary_sensor", config) @@ -636,12 +677,150 @@ async def test_dst( await hass.async_block_till_done() state = hass.states.get(entity_id) - assert state.attributes["after"] == "2019-03-31T03:30:00+02:00" - assert state.attributes["before"] == "2019-03-31T03:40:00+02:00" - assert state.attributes["next_update"] == "2019-03-31T03:30:00+02:00" + assert state.attributes["after"] == "2019-03-31T04:30:00+02:00" + assert state.attributes["before"] == "2019-03-31T04:40:00+02:00" + assert state.attributes["next_update"] == "2019-03-31T04:30:00+02:00" assert state.state == STATE_OFF +async def test_dst3(hass, freezer, hass_tz_info): + """Test DST when there's a time switch forward in the West.""" + hass.config.time_zone = "US/Pacific" + dt_util.set_default_time_zone(dt_util.get_time_zone("US/Pacific")) + test_time = datetime( + 2023, 3, 11, 5, 0, 0, tzinfo=dt_util.get_time_zone("US/Pacific") + ) + config = { + "binary_sensor": [ + {"platform": "tod", "name": "Day", "after": "4:30", "before": "4:40"} + ] + } + # Test DST #3: + # after 2023-03-11 05:00 Pacific the next update should ge scheduled + # at 4:30-07 not 4:30-08 + entity_id = "binary_sensor.day" + freezer.move_to(test_time) + await async_setup_component(hass, "binary_sensor", config) + await hass.async_block_till_done() + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes["after"] == "2023-03-12T04:30:00-07:00" + assert state.attributes["before"] == "2023-03-12T04:40:00-07:00" + assert state.attributes["next_update"] == "2023-03-12T04:30:00-07:00" + assert state.state == STATE_OFF + + +async def test_dst4(hass, freezer, hass_tz_info): + """Test DST when there's a time switch backward in the West.""" + hass.config.time_zone = "US/Pacific" + dt_util.set_default_time_zone(dt_util.get_time_zone("US/Pacific")) + test_time = datetime( + 2023, 11, 4, 5, 0, 0, tzinfo=dt_util.get_time_zone("US/Pacific") + ) + config = { + "binary_sensor": [ + {"platform": "tod", "name": "Day", "after": "4:30", "before": "4:40"} + ] + } + # Test DST #4: + # after 2023-11-04 05:00 Pacific the next update should ge scheduled + # at 4:30-08 not 4:30-07 + entity_id = "binary_sensor.day" + freezer.move_to(test_time) + await async_setup_component(hass, "binary_sensor", config) + await hass.async_block_till_done() + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes["after"] == "2023-11-05T04:30:00-08:00" + assert state.attributes["before"] == "2023-11-05T04:40:00-08:00" + assert state.attributes["next_update"] == "2023-11-05T04:30:00-08:00" + assert state.state == STATE_OFF + + +async def test_dst5( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info +) -> None: + """Test DST when end time falls in non-existent hour (1:50am-2:10am).""" + hass.config.time_zone = "CET" + dt_util.set_default_time_zone(dt_util.get_time_zone("CET")) + test_time1 = datetime(2019, 3, 30, 3, 0, 0, tzinfo=dt_util.get_time_zone("CET")) + test_time2 = datetime(2019, 3, 31, 1, 51, 0, tzinfo=dt_util.get_time_zone("CET")) + config = { + "binary_sensor": [ + {"platform": "tod", "name": "Day", "after": "1:50", "before": "2:10"} + ] + } + # Test DST #5: + # Test the case where the end time does not exist (roll out to the next available time) + # First test before the sensor is turned on + entity_id = "binary_sensor.day" + freezer.move_to(test_time1) + await async_setup_component(hass, "binary_sensor", config) + await hass.async_block_till_done() + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes["after"] == "2019-03-31T01:50:00+01:00" + assert state.attributes["before"] == "2019-03-31T03:00:00+02:00" + assert state.attributes["next_update"] == "2019-03-31T01:50:00+01:00" + assert state.state == STATE_OFF + + # Seconds, test state when sensor is ON but end time has rolled out to next available time. + freezer.move_to(test_time2) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes["after"] == "2019-03-31T01:50:00+01:00" + assert state.attributes["before"] == "2019-03-31T03:00:00+02:00" + assert state.attributes["next_update"] == "2019-03-31T03:00:00+02:00" + + assert state.state == STATE_ON + + +async def test_dst6( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info +) -> None: + """Test DST when start time falls in non-existent hour (2:50am 3:10am).""" + hass.config.time_zone = "CET" + dt_util.set_default_time_zone(dt_util.get_time_zone("CET")) + test_time1 = datetime(2019, 3, 30, 4, 0, 0, tzinfo=dt_util.get_time_zone("CET")) + test_time2 = datetime(2019, 3, 31, 3, 1, 0, tzinfo=dt_util.get_time_zone("CET")) + config = { + "binary_sensor": [ + {"platform": "tod", "name": "Day", "after": "2:50", "before": "3:10"} + ] + } + # Test DST #6: + # Test the case where the end time does not exist (roll out to the next available time) + # First test before the sensor is turned on + entity_id = "binary_sensor.day" + freezer.move_to(test_time1) + await async_setup_component(hass, "binary_sensor", config) + await hass.async_block_till_done() + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes["after"] == "2019-03-31T03:00:00+02:00" + assert state.attributes["before"] == "2019-03-31T03:10:00+02:00" + assert state.attributes["next_update"] == "2019-03-31T03:00:00+02:00" + assert state.state == STATE_OFF + + # Seconds, test state when sensor is ON but end time has rolled out to next available time. + freezer.move_to(test_time2) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes["after"] == "2019-03-31T03:00:00+02:00" + assert state.attributes["before"] == "2019-03-31T03:10:00+02:00" + assert state.attributes["next_update"] == "2019-03-31T03:10:00+02:00" + + assert state.state == STATE_ON + + @pytest.mark.freeze_time("2019-01-10 18:43:00") @pytest.mark.parametrize("hass_time_zone", ("UTC",)) async def test_simple_before_after_does_not_loop_utc_not_in_range( diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 3e84049efa8..90b06858e00 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -1,8 +1,10 @@ """Tests for the todo integration.""" from collections.abc import Generator +import datetime from typing import Any from unittest.mock import AsyncMock +import zoneinfo import pytest import voluptuous as vol @@ -13,11 +15,13 @@ from homeassistant.components.todo import ( TodoItemStatus, TodoListEntity, TodoListEntityFeature, + intent as todo_intent, ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddEntitiesCallback from tests.common import ( @@ -31,12 +35,45 @@ from tests.common import ( from tests.typing import WebSocketGenerator TEST_DOMAIN = "test" +ITEM_1 = { + "uid": "1", + "summary": "Item #1", + "status": "needs_action", +} +ITEM_2 = { + "uid": "2", + "summary": "Item #2", + "status": "completed", +} +TEST_TIMEZONE = zoneinfo.ZoneInfo("America/Regina") +TEST_OFFSET = "-06:00" class MockFlow(ConfigFlow): """Test flow.""" +class MockTodoListEntity(TodoListEntity): + """Test todo list entity.""" + + def __init__(self, items: list[TodoItem] | None = None) -> None: + """Initialize entity.""" + self._attr_todo_items = items or [] + + @property + def items(self) -> list[TodoItem]: + """Return the items in the To-do list.""" + return self._attr_todo_items + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Add an item to the To-do list.""" + self._attr_todo_items.append(item) + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Delete an item in the To-do list.""" + self._attr_todo_items = [item for item in self.items if item.uid not in uids] + + @pytest.fixture(autouse=True) def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: """Mock config flow.""" @@ -75,6 +112,12 @@ def mock_setup_integration(hass: HomeAssistant) -> None: ) +@pytest.fixture(autouse=True) +def set_time_zone(hass: HomeAssistant) -> None: + """Set the time zone for the tests that keesp UTC-6 all year round.""" + hass.config.set_time_zone("America/Regina") + + async def create_mock_platform( hass: HomeAssistant, entities: list[TodoListEntity], @@ -106,7 +149,12 @@ async def create_mock_platform( @pytest.fixture(name="test_entity") def mock_test_entity() -> TodoListEntity: """Fixture that creates a test TodoList entity with mock service calls.""" - entity1 = TodoListEntity() + entity1 = MockTodoListEntity( + [ + TodoItem(summary="Item #1", uid="1", status=TodoItemStatus.NEEDS_ACTION), + TodoItem(summary="Item #2", uid="2", status=TodoItemStatus.COMPLETED), + ] + ) entity1.entity_id = "todo.entity1" entity1._attr_supported_features = ( TodoListEntityFeature.CREATE_TODO_ITEM @@ -114,13 +162,9 @@ def mock_test_entity() -> TodoListEntity: | TodoListEntityFeature.DELETE_TODO_ITEM | TodoListEntityFeature.MOVE_TODO_ITEM ) - entity1._attr_todo_items = [ - TodoItem(summary="Item #1", uid="1", status=TodoItemStatus.NEEDS_ACTION), - TodoItem(summary="Item #2", uid="2", status=TodoItemStatus.COMPLETED), - ] - entity1.async_create_todo_item = AsyncMock() + entity1.async_create_todo_item = AsyncMock(wraps=entity1.async_create_todo_item) entity1.async_update_todo_item = AsyncMock() - entity1.async_delete_todo_items = AsyncMock() + entity1.async_delete_todo_items = AsyncMock(wraps=entity1.async_delete_todo_items) entity1.async_move_todo_item = AsyncMock() return entity1 @@ -168,17 +212,68 @@ async def test_list_todo_items( assert resp.get("success") assert resp.get("result") == { "items": [ - {"summary": "Item #1", "uid": "1", "status": "needs_action"}, - {"summary": "Item #2", "uid": "2", "status": "completed"}, + ITEM_1, + ITEM_2, ] } +@pytest.mark.parametrize( + ("service_data", "expected_items"), + [ + ({}, [ITEM_1, ITEM_2]), + ( + [ + {"status": [TodoItemStatus.COMPLETED, TodoItemStatus.NEEDS_ACTION]}, + [ITEM_1, ITEM_2], + ] + ), + ( + [ + {"status": [TodoItemStatus.NEEDS_ACTION]}, + [ITEM_1], + ] + ), + ( + [ + {"status": [TodoItemStatus.COMPLETED]}, + [ITEM_2], + ] + ), + ], +) +async def test_get_items_service( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + test_entity: TodoListEntity, + service_data: dict[str, Any], + expected_items: list[dict[str, Any]], +) -> None: + """Test listing items in a To-do list from a service call.""" + + await create_mock_platform(hass, [test_entity]) + + state = hass.states.get("todo.entity1") + assert state + assert state.state == "1" + assert state.attributes == {"supported_features": 15} + + result = await hass.services.async_call( + DOMAIN, + "get_items", + service_data, + target={"entity_id": "todo.entity1"}, + blocking=True, + return_response=True, + ) + assert result == {"todo.entity1": {"items": expected_items}} + + async def test_unsupported_websocket( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: - """Test a To-do list that does not support features.""" + """Test a To-do list for an entity that does not exist.""" entity1 = TodoListEntity() entity1.entity_id = "todo.entity1" @@ -242,23 +337,42 @@ async def test_add_item_service_raises( @pytest.mark.parametrize( - ("item_data", "expected_error"), + ("item_data", "expected_exception", "expected_error"), [ - ({}, "required key not provided"), - ({"item": ""}, "length of value must be at least 1"), + ({}, vol.Invalid, "required key not provided"), + ({"item": ""}, vol.Invalid, "length of value must be at least 1"), + ( + {"item": "Submit forms", "description": "Submit tax forms"}, + ValueError, + "does not support setting field 'description'", + ), + ( + {"item": "Submit forms", "due_date": "2023-11-17"}, + ValueError, + "does not support setting field 'due_date'", + ), + ( + { + "item": "Submit forms", + "due_datetime": f"2023-11-17T17:00:00{TEST_OFFSET}", + }, + ValueError, + "does not support setting field 'due_datetime'", + ), ], ) async def test_add_item_service_invalid_input( hass: HomeAssistant, test_entity: TodoListEntity, item_data: dict[str, Any], + expected_exception: str, expected_error: str, ) -> None: """Test invalid input to the add item service.""" await create_mock_platform(hass, [test_entity]) - with pytest.raises(vol.Invalid, match=expected_error): + with pytest.raises(expected_exception, match=expected_error): await hass.services.async_call( DOMAIN, "add_item", @@ -268,6 +382,82 @@ async def test_add_item_service_invalid_input( ) +@pytest.mark.parametrize( + ("supported_entity_feature", "item_data", "expected_item"), + ( + ( + TodoListEntityFeature.SET_DUE_DATE_ON_ITEM, + {"item": "New item", "due_date": "2023-11-13"}, + TodoItem( + summary="New item", + status=TodoItemStatus.NEEDS_ACTION, + due=datetime.date(2023, 11, 13), + ), + ), + ( + TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, + {"item": "New item", "due_datetime": f"2023-11-13T17:00:00{TEST_OFFSET}"}, + TodoItem( + summary="New item", + status=TodoItemStatus.NEEDS_ACTION, + due=datetime.datetime(2023, 11, 13, 17, 00, 00, tzinfo=TEST_TIMEZONE), + ), + ), + ( + TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, + {"item": "New item", "due_datetime": "2023-11-13T17:00:00+00:00"}, + TodoItem( + summary="New item", + status=TodoItemStatus.NEEDS_ACTION, + due=datetime.datetime(2023, 11, 13, 11, 00, 00, tzinfo=TEST_TIMEZONE), + ), + ), + ( + TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, + {"item": "New item", "due_datetime": "2023-11-13"}, + TodoItem( + summary="New item", + status=TodoItemStatus.NEEDS_ACTION, + due=datetime.datetime(2023, 11, 13, 0, 00, 00, tzinfo=TEST_TIMEZONE), + ), + ), + ( + TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM, + {"item": "New item", "description": "Submit revised draft"}, + TodoItem( + summary="New item", + status=TodoItemStatus.NEEDS_ACTION, + description="Submit revised draft", + ), + ), + ), +) +async def test_add_item_service_extended_fields( + hass: HomeAssistant, + test_entity: TodoListEntity, + supported_entity_feature: int, + item_data: dict[str, Any], + expected_item: TodoItem, +) -> None: + """Test adding an item in a To-do list.""" + + test_entity._attr_supported_features |= supported_entity_feature + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "add_item", + {"item": "New item", **item_data}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_create_todo_item.call_args + assert args + item = args.kwargs.get("item") + assert item == expected_item + + async def test_update_todo_item_service_by_id( hass: HomeAssistant, test_entity: TodoListEntity, @@ -470,6 +660,82 @@ async def test_update_item_service_invalid_input( ) +@pytest.mark.parametrize( + ("update_data"), + [ + ({"due_datetime": f"2023-11-13T17:00:00{TEST_OFFSET}"}), + ({"due_date": "2023-11-13"}), + ({"description": "Submit revised draft"}), + ], +) +async def test_update_todo_item_field_unsupported( + hass: HomeAssistant, + test_entity: TodoListEntity, + update_data: dict[str, Any], +) -> None: + """Test updating an item in a To-do list.""" + + await create_mock_platform(hass, [test_entity]) + + with pytest.raises(ValueError, match="does not support"): + await hass.services.async_call( + DOMAIN, + "update_item", + {"item": "1", **update_data}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("supported_entity_feature", "update_data", "expected_update"), + ( + ( + TodoListEntityFeature.SET_DUE_DATE_ON_ITEM, + {"due_date": "2023-11-13"}, + TodoItem(uid="1", due=datetime.date(2023, 11, 13)), + ), + ( + TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, + {"due_datetime": f"2023-11-13T17:00:00{TEST_OFFSET}"}, + TodoItem( + uid="1", + due=datetime.datetime(2023, 11, 13, 17, 0, 0, tzinfo=TEST_TIMEZONE), + ), + ), + ( + TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM, + {"description": "Submit revised draft"}, + TodoItem(uid="1", description="Submit revised draft"), + ), + ), +) +async def test_update_todo_item_extended_fields( + hass: HomeAssistant, + test_entity: TodoListEntity, + supported_entity_feature: int, + update_data: dict[str, Any], + expected_update: TodoItem, +) -> None: + """Test updating an item in a To-do list.""" + + test_entity._attr_supported_features |= supported_entity_feature + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "update_item", + {"item": "1", **update_data}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_update_todo_item.call_args + assert args + item = args.kwargs.get("item") + assert item == expected_update + + async def test_remove_todo_item_service_by_id( hass: HomeAssistant, test_entity: TodoListEntity, @@ -688,12 +954,16 @@ async def test_move_todo_item_service_invalid_input( "rename": "Updated item", }, ), + ( + "remove_completed_items", + None, + ), ], ) async def test_unsupported_service( hass: HomeAssistant, service_name: str, - payload: dict[str, Any], + payload: dict[str, Any] | None, ) -> None: """Test a To-do list that does not support features.""" @@ -737,3 +1007,294 @@ async def test_move_item_unsupported( resp = await client.receive_json() assert resp.get("id") == 1 assert resp.get("error", {}).get("code") == "not_supported" + + +async def test_add_item_intent( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test adding items to lists using an intent.""" + await todo_intent.async_setup_intents(hass) + + entity1 = MockTodoListEntity() + entity1._attr_name = "List 1" + entity1.entity_id = "todo.list_1" + + entity2 = MockTodoListEntity() + entity2._attr_name = "List 2" + entity2.entity_id = "todo.list_2" + + await create_mock_platform(hass, [entity1, entity2]) + + # Add to first list + response = await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": "beer"}, "name": {"value": "list 1"}}, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + assert len(entity1.items) == 1 + assert len(entity2.items) == 0 + assert entity1.items[0].summary == "beer" + entity1.items.clear() + + # Add to second list + response = await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": "cheese"}, "name": {"value": "List 2"}}, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + assert len(entity1.items) == 0 + assert len(entity2.items) == 1 + assert entity2.items[0].summary == "cheese" + + # List name is case insensitive + response = await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": "wine"}, "name": {"value": "lIST 2"}}, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + assert len(entity1.items) == 0 + assert len(entity2.items) == 2 + assert entity2.items[1].summary == "wine" + + # Missing list + with pytest.raises(intent.IntentHandleError): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": "wine"}, "name": {"value": "This list does not exist"}}, + ) + + +async def test_remove_completed_items_service( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test remove completed todo items service.""" + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "remove_completed_items", + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_delete_todo_items.call_args + assert args + assert args.kwargs.get("uids") == ["2"] + + test_entity.async_delete_todo_items.reset_mock() + + # calling service multiple times will not call the entity method + await hass.services.async_call( + DOMAIN, + "remove_completed_items", + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + test_entity.async_delete_todo_items.assert_not_called() + + +async def test_remove_completed_items_service_raises( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test removing all completed item from a To-do list that raises an error.""" + + await create_mock_platform(hass, [test_entity]) + + test_entity.async_delete_todo_items.side_effect = HomeAssistantError("Ooops") + with pytest.raises(HomeAssistantError, match="Ooops"): + await hass.services.async_call( + DOMAIN, + "remove_completed_items", + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + +async def test_subscribe( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + test_entity: TodoListEntity, +) -> None: + """Test subscribing to todo updates.""" + + await create_mock_platform(hass, [test_entity]) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "todo/item/subscribe", + "entity_id": test_entity.entity_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + event_message = msg["event"] + assert event_message == { + "items": [ + { + "summary": "Item #1", + "uid": "1", + "status": "needs_action", + "due": None, + "description": None, + }, + { + "summary": "Item #2", + "uid": "2", + "status": "completed", + "due": None, + "description": None, + }, + ] + } + test_entity._attr_todo_items = [ + *test_entity._attr_todo_items, + TodoItem(summary="Item #3", uid="3", status=TodoItemStatus.NEEDS_ACTION), + ] + + test_entity.async_write_ha_state() + msg = await client.receive_json() + event_message = msg["event"] + assert event_message == { + "items": [ + { + "summary": "Item #1", + "uid": "1", + "status": "needs_action", + "due": None, + "description": None, + }, + { + "summary": "Item #2", + "uid": "2", + "status": "completed", + "due": None, + "description": None, + }, + { + "summary": "Item #3", + "uid": "3", + "status": "needs_action", + "due": None, + "description": None, + }, + ] + } + + test_entity._attr_todo_items = None + test_entity.async_write_ha_state() + msg = await client.receive_json() + event_message = msg["event"] + assert event_message == { + "items": [], + } + + +async def test_subscribe_entity_does_not_exist( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + test_entity: TodoListEntity, +) -> None: + """Test failure to subscribe to an entity that does not exist.""" + + await create_mock_platform(hass, [test_entity]) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "todo/item/subscribe", + "entity_id": "todo.unknown", + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "invalid_entity_id", + "message": "To-do list entity not found: todo.unknown", + } + + +@pytest.mark.parametrize( + ("item_data", "expected_item_data"), + [ + ({"due": datetime.date(2023, 11, 17)}, {"due": "2023-11-17"}), + ( + {"due": datetime.datetime(2023, 11, 17, 17, 0, 0, tzinfo=TEST_TIMEZONE)}, + {"due": f"2023-11-17T17:00:00{TEST_OFFSET}"}, + ), + ({"description": "Some description"}, {"description": "Some description"}), + ], +) +async def test_list_todo_items_extended_fields( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + test_entity: TodoListEntity, + item_data: dict[str, Any], + expected_item_data: dict[str, Any], +) -> None: + """Test listing items in a To-do list with extended fields.""" + + test_entity._attr_todo_items = [ + TodoItem( + **ITEM_1, + **item_data, + ), + ] + await create_mock_platform(hass, [test_entity]) + + client = await hass_ws_client(hass) + await client.send_json( + {"id": 1, "type": "todo/item/list", "entity_id": "todo.entity1"} + ) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert resp.get("success") + assert resp.get("result") == { + "items": [ + { + **ITEM_1, + **expected_item_data, + }, + ] + } + + result = await hass.services.async_call( + DOMAIN, + "get_items", + {}, + target={"entity_id": "todo.entity1"}, + blocking=True, + return_response=True, + ) + assert result == { + "todo.entity1": { + "items": [ + { + **ITEM_1, + **expected_item_data, + }, + ] + } + } diff --git a/tests/components/todoist/conftest.py b/tests/components/todoist/conftest.py index 28f22e1061a..42251b0ea18 100644 --- a/tests/components/todoist/conftest.py +++ b/tests/components/todoist/conftest.py @@ -45,6 +45,8 @@ def make_api_task( is_completed: bool = False, due: Due | None = None, project_id: str | None = None, + description: str | None = None, + parent_id: str | None = None, ) -> Task: """Mock a todoist Task instance.""" return Task( @@ -55,12 +57,12 @@ def make_api_task( content=content or SUMMARY, created_at="2021-10-01T00:00:00", creator_id="1", - description="A task", - due=due or Due(is_recurring=False, date=TODAY, string="today"), + description=description, + due=due, id=id or "1", labels=["Label1"], order=1, - parent_id=None, + parent_id=parent_id, priority=1, project_id=project_id or PROJECT_ID, section_id=None, diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py index a14f362ea5b..1e94b52149c 100644 --- a/tests/components/todoist/test_todo.py +++ b/tests/components/todoist/test_todo.py @@ -1,7 +1,9 @@ """Unit tests for the Todoist todo platform.""" +from typing import Any from unittest.mock import AsyncMock import pytest +from todoist_api_python.models import Due, Task from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.const import Platform @@ -10,6 +12,8 @@ from homeassistant.helpers.entity_component import async_update_entity from .conftest import PROJECT_ID, make_api_task +from tests.typing import WebSocketGenerator + @pytest.fixture(autouse=True) def platforms() -> list[Platform]: @@ -17,6 +21,12 @@ def platforms() -> list[Platform]: return [Platform.TODO] +@pytest.fixture(autouse=True) +def set_time_zone(hass: HomeAssistant) -> None: + """Set the time zone for the tests that keesp UTC-6 all year round.""" + hass.config.set_time_zone("America/Regina") + + @pytest.mark.parametrize( ("tasks", "expected_state"), [ @@ -41,6 +51,14 @@ def platforms() -> list[Platform]: ], "0", ), + ( + [ + make_api_task( + id="12345", content="sub-task", is_completed=False, parent_id="1" + ) + ], + "0", + ), ], ) async def test_todo_item_state( @@ -55,11 +73,91 @@ async def test_todo_item_state( assert state.state == expected_state -@pytest.mark.parametrize(("tasks"), [[]]) +@pytest.mark.parametrize( + ("tasks", "item_data", "tasks_after_update", "add_kwargs", "expected_item"), + [ + ( + [], + {}, + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"content": "Soda"}, + {"uid": "task-id-1", "summary": "Soda", "status": "needs_action"}, + ), + ( + [], + {"due_date": "2023-11-18"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + due=Due(is_recurring=False, date="2023-11-18", string="today"), + ) + ], + {"due": {"date": "2023-11-18"}}, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "due": "2023-11-18", + }, + ), + ( + [], + {"due_datetime": "2023-11-18T06:30:00"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + due=Due( + date="2023-11-18", + is_recurring=False, + datetime="2023-11-18T12:30:00.000000Z", + string="today", + ), + ) + ], + { + "due": {"date": "2023-11-18", "datetime": "2023-11-18T06:30:00-06:00"}, + }, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "due": "2023-11-18T06:30:00-06:00", + }, + ), + ( + [], + {"description": "6-pack"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + description="6-pack", + is_completed=False, + ) + ], + {"description": "6-pack"}, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "description": "6-pack", + }, + ), + ], + ids=["summary", "due_date", "due_datetime", "description"], +) async def test_add_todo_list_item( hass: HomeAssistant, setup_integration: None, api: AsyncMock, + item_data: dict[str, Any], + tasks_after_update: list[Task], + add_kwargs: dict[str, Any], + expected_item: dict[str, Any], ) -> None: """Test for adding a To-do Item.""" @@ -69,28 +167,35 @@ async def test_add_todo_list_item( api.add_task = AsyncMock() # Fake API response when state is refreshed after create - api.get_tasks.return_value = [ - make_api_task(id="task-id-1", content="Soda", is_completed=False) - ] + api.get_tasks.return_value = tasks_after_update await hass.services.async_call( TODO_DOMAIN, "add_item", - {"item": "Soda"}, + {"item": "Soda", **item_data}, target={"entity_id": "todo.name"}, blocking=True, ) args = api.add_task.call_args assert args - assert args.kwargs.get("content") == "Soda" - assert args.kwargs.get("project_id") == PROJECT_ID + assert args.kwargs == {"project_id": PROJECT_ID, "content": "Soda", **add_kwargs} # Verify state is refreshed state = hass.states.get("todo.name") assert state assert state.state == "1" + result = await hass.services.async_call( + TODO_DOMAIN, + "get_items", + {}, + target={"entity_id": "todo.name"}, + blocking=True, + return_response=True, + ) + assert result == {"todo.name": {"items": [expected_item]}} + @pytest.mark.parametrize( ("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] @@ -156,12 +261,91 @@ async def test_update_todo_item_status( @pytest.mark.parametrize( - ("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] + ("tasks", "update_data", "tasks_after_update", "update_kwargs", "expected_item"), + [ + ( + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"rename": "Milk"}, + [make_api_task(id="task-id-1", content="Milk", is_completed=False)], + {"task_id": "task-id-1", "content": "Milk"}, + {"uid": "task-id-1", "summary": "Milk", "status": "needs_action"}, + ), + ( + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"due_date": "2023-11-18"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + due=Due(is_recurring=False, date="2023-11-18", string="today"), + ) + ], + {"task_id": "task-id-1", "due": {"date": "2023-11-18"}}, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "due": "2023-11-18", + }, + ), + ( + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"due_datetime": "2023-11-18T06:30:00"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + due=Due( + date="2023-11-18", + is_recurring=False, + datetime="2023-11-18T12:30:00.000000Z", + string="today", + ), + ) + ], + { + "task_id": "task-id-1", + "due": {"date": "2023-11-18", "datetime": "2023-11-18T06:30:00-06:00"}, + }, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "due": "2023-11-18T06:30:00-06:00", + }, + ), + ( + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"description": "6-pack"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + description="6-pack", + is_completed=False, + ) + ], + {"task_id": "task-id-1", "description": "6-pack"}, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "description": "6-pack", + }, + ), + ], + ids=["rename", "due_date", "due_datetime", "description"], ) -async def test_update_todo_item_summary( +async def test_update_todo_items( hass: HomeAssistant, setup_integration: None, api: AsyncMock, + update_data: dict[str, Any], + tasks_after_update: list[Task], + update_kwargs: dict[str, Any], + expected_item: dict[str, Any], ) -> None: """Test for updating a To-do Item that changes the summary.""" @@ -172,22 +356,29 @@ async def test_update_todo_item_summary( api.update_task = AsyncMock() # Fake API response when state is refreshed after close - api.get_tasks.return_value = [ - make_api_task(id="task-id-1", content="Soda", is_completed=True) - ] + api.get_tasks.return_value = tasks_after_update await hass.services.async_call( TODO_DOMAIN, "update_item", - {"item": "task-id-1", "rename": "Milk"}, + {"item": "task-id-1", **update_data}, target={"entity_id": "todo.name"}, blocking=True, ) assert api.update_task.called args = api.update_task.call_args assert args - assert args.kwargs.get("task_id") == "task-id-1" - assert args.kwargs.get("content") == "Milk" + assert args.kwargs == update_kwargs + + result = await hass.services.async_call( + TODO_DOMAIN, + "get_items", + {}, + target={"entity_id": "todo.name"}, + blocking=True, + return_response=True, + ) + assert result == {"todo.name": {"items": [expected_item]}} @pytest.mark.parametrize( @@ -230,3 +421,61 @@ async def test_remove_todo_item( state = hass.states.get("todo.name") assert state assert state.state == "0" + + +@pytest.mark.parametrize( + ("tasks"), [[make_api_task(id="task-id-1", content="Cheese", is_completed=False)]] +) +async def test_subscribe( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test for subscribing to state updates.""" + + # Subscribe and get the initial list + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "todo/item/subscribe", + "entity_id": "todo.name", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "Cheese" + assert items[0]["status"] == "needs_action" + assert items[0]["uid"] + + # Fake API response when state is refreshed + api.get_tasks.return_value = [ + make_api_task(id="test-id-1", content="Wine", is_completed=False) + ] + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": "Cheese", "rename": "Wine"}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + + # Verify update is published + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "Wine" + assert items[0]["status"] == "needs_action" + assert items[0]["uid"] diff --git a/tests/components/tomorrowio/snapshots/test_weather.ambr b/tests/components/tomorrowio/snapshots/test_weather.ambr index a938cb10e44..fe65925e4c7 100644 --- a/tests/components/tomorrowio/snapshots/test_weather.ambr +++ b/tests/components/tomorrowio/snapshots/test_weather.ambr @@ -1107,3 +1107,1127 @@ ]), }) # --- +# name: test_v4_forecast_service[forecast] + dict({ + 'weather.tomorrow_io_daily': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T11:00:00+00:00', + 'dew_point': 12.8, + 'humidity': 58, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.9, + 'templow': 26.1, + 'wind_bearing': 239.6, + 'wind_speed': 34.16, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 49.4, + 'templow': 26.3, + 'wind_bearing': 262.82, + 'wind_speed': 26.06, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-09T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 67.0, + 'templow': 31.5, + 'wind_bearing': 229.3, + 'wind_speed': 25.38, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-10T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 65.3, + 'templow': 37.3, + 'wind_bearing': 149.91, + 'wind_speed': 38.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-11T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 66.2, + 'templow': 48.3, + 'wind_bearing': 210.45, + 'wind_speed': 56.48, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-03-12T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 67.9, + 'templow': 53.8, + 'wind_bearing': 217.98, + 'wind_speed': 44.28, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-13T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 54.5, + 'templow': 42.9, + 'wind_bearing': 58.79, + 'wind_speed': 34.99, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-14T10:00:00+00:00', + 'precipitation': 0.94, + 'precipitation_probability': 95, + 'temperature': 42.9, + 'templow': 33.4, + 'wind_bearing': 70.25, + 'wind_speed': 58.5, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-15T10:00:00+00:00', + 'precipitation': 0.06, + 'precipitation_probability': 55, + 'temperature': 43.7, + 'templow': 29.4, + 'wind_bearing': 84.47, + 'wind_speed': 57.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-16T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 43.0, + 'templow': 29.1, + 'wind_bearing': 103.85, + 'wind_speed': 24.16, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-17T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 52.4, + 'templow': 34.3, + 'wind_bearing': 145.41, + 'wind_speed': 26.17, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-18T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 54.1, + 'templow': 41.3, + 'wind_bearing': 62.99, + 'wind_speed': 23.69, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-03-19T10:00:00+00:00', + 'precipitation': 0.12, + 'precipitation_probability': 55, + 'temperature': 48.9, + 'templow': 39.4, + 'wind_bearing': 68.54, + 'wind_speed': 50.08, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-20T10:00:00+00:00', + 'precipitation': 0.05, + 'precipitation_probability': 33, + 'temperature': 40.1, + 'templow': 35.1, + 'wind_bearing': 56.98, + 'wind_speed': 62.46, + }), + ]), + }), + }) +# --- +# name: test_v4_forecast_service[forecast].1 + dict({ + 'weather.tomorrow_io_daily': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T17:48:00+00:00', + 'dew_point': 12.8, + 'humidity': 58, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.1, + 'wind_bearing': 315.14, + 'wind_speed': 33.59, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T18:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.8, + 'wind_bearing': 321.71, + 'wind_speed': 31.82, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T19:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.8, + 'wind_bearing': 323.38, + 'wind_speed': 32.04, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T20:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.3, + 'wind_bearing': 318.43, + 'wind_speed': 33.73, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T21:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.6, + 'wind_bearing': 320.9, + 'wind_speed': 28.98, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T22:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 41.9, + 'wind_bearing': 322.11, + 'wind_speed': 15.7, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T23:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 38.9, + 'wind_bearing': 295.94, + 'wind_speed': 17.78, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-08T00:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 36.2, + 'wind_bearing': 11.94, + 'wind_speed': 20.12, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-08T01:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 34.3, + 'wind_bearing': 13.68, + 'wind_speed': 20.05, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T02:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 32.9, + 'wind_bearing': 14.93, + 'wind_speed': 19.48, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T03:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 31.9, + 'wind_bearing': 26.07, + 'wind_speed': 16.6, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T04:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 29.2, + 'wind_bearing': 51.27, + 'wind_speed': 9.32, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T05:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 27.4, + 'wind_bearing': 343.25, + 'wind_speed': 11.92, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T06:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.7, + 'wind_bearing': 341.46, + 'wind_speed': 15.37, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T07:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.4, + 'wind_bearing': 322.34, + 'wind_speed': 12.71, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T08:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.1, + 'wind_bearing': 294.69, + 'wind_speed': 13.14, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T09:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 30.1, + 'wind_bearing': 325.32, + 'wind_speed': 11.52, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T10:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 31.0, + 'wind_bearing': 322.27, + 'wind_speed': 10.22, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T11:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 27.2, + 'wind_bearing': 310.14, + 'wind_speed': 20.12, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T12:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 29.2, + 'wind_bearing': 324.8, + 'wind_speed': 25.38, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-03-08T13:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 33.2, + 'wind_bearing': 335.16, + 'wind_speed': 23.26, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T14:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 37.0, + 'wind_bearing': 324.49, + 'wind_speed': 21.17, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T15:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 40.0, + 'wind_bearing': 310.68, + 'wind_speed': 19.98, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-03-08T16:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 42.4, + 'wind_bearing': 304.18, + 'wind_speed': 19.66, + }), + ]), + }), + }) +# --- +# name: test_v4_forecast_service[get_forecast] + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T11:00:00+00:00', + 'dew_point': 12.8, + 'humidity': 58, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.9, + 'templow': 26.1, + 'wind_bearing': 239.6, + 'wind_speed': 34.16, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 49.4, + 'templow': 26.3, + 'wind_bearing': 262.82, + 'wind_speed': 26.06, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-09T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 67.0, + 'templow': 31.5, + 'wind_bearing': 229.3, + 'wind_speed': 25.38, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-10T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 65.3, + 'templow': 37.3, + 'wind_bearing': 149.91, + 'wind_speed': 38.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-11T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 66.2, + 'templow': 48.3, + 'wind_bearing': 210.45, + 'wind_speed': 56.48, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-03-12T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 67.9, + 'templow': 53.8, + 'wind_bearing': 217.98, + 'wind_speed': 44.28, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-13T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 54.5, + 'templow': 42.9, + 'wind_bearing': 58.79, + 'wind_speed': 34.99, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-14T10:00:00+00:00', + 'precipitation': 0.94, + 'precipitation_probability': 95, + 'temperature': 42.9, + 'templow': 33.4, + 'wind_bearing': 70.25, + 'wind_speed': 58.5, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-15T10:00:00+00:00', + 'precipitation': 0.06, + 'precipitation_probability': 55, + 'temperature': 43.7, + 'templow': 29.4, + 'wind_bearing': 84.47, + 'wind_speed': 57.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-16T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 43.0, + 'templow': 29.1, + 'wind_bearing': 103.85, + 'wind_speed': 24.16, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-17T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 52.4, + 'templow': 34.3, + 'wind_bearing': 145.41, + 'wind_speed': 26.17, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-18T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 54.1, + 'templow': 41.3, + 'wind_bearing': 62.99, + 'wind_speed': 23.69, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-03-19T10:00:00+00:00', + 'precipitation': 0.12, + 'precipitation_probability': 55, + 'temperature': 48.9, + 'templow': 39.4, + 'wind_bearing': 68.54, + 'wind_speed': 50.08, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-20T10:00:00+00:00', + 'precipitation': 0.05, + 'precipitation_probability': 33, + 'temperature': 40.1, + 'templow': 35.1, + 'wind_bearing': 56.98, + 'wind_speed': 62.46, + }), + ]), + }) +# --- +# name: test_v4_forecast_service[get_forecast].1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T17:48:00+00:00', + 'dew_point': 12.8, + 'humidity': 58, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.1, + 'wind_bearing': 315.14, + 'wind_speed': 33.59, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T18:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.8, + 'wind_bearing': 321.71, + 'wind_speed': 31.82, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T19:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.8, + 'wind_bearing': 323.38, + 'wind_speed': 32.04, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T20:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.3, + 'wind_bearing': 318.43, + 'wind_speed': 33.73, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T21:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.6, + 'wind_bearing': 320.9, + 'wind_speed': 28.98, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T22:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 41.9, + 'wind_bearing': 322.11, + 'wind_speed': 15.7, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T23:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 38.9, + 'wind_bearing': 295.94, + 'wind_speed': 17.78, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-08T00:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 36.2, + 'wind_bearing': 11.94, + 'wind_speed': 20.12, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-08T01:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 34.3, + 'wind_bearing': 13.68, + 'wind_speed': 20.05, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T02:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 32.9, + 'wind_bearing': 14.93, + 'wind_speed': 19.48, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T03:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 31.9, + 'wind_bearing': 26.07, + 'wind_speed': 16.6, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T04:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 29.2, + 'wind_bearing': 51.27, + 'wind_speed': 9.32, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T05:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 27.4, + 'wind_bearing': 343.25, + 'wind_speed': 11.92, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T06:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.7, + 'wind_bearing': 341.46, + 'wind_speed': 15.37, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T07:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.4, + 'wind_bearing': 322.34, + 'wind_speed': 12.71, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T08:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.1, + 'wind_bearing': 294.69, + 'wind_speed': 13.14, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T09:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 30.1, + 'wind_bearing': 325.32, + 'wind_speed': 11.52, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T10:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 31.0, + 'wind_bearing': 322.27, + 'wind_speed': 10.22, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T11:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 27.2, + 'wind_bearing': 310.14, + 'wind_speed': 20.12, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T12:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 29.2, + 'wind_bearing': 324.8, + 'wind_speed': 25.38, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-03-08T13:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 33.2, + 'wind_bearing': 335.16, + 'wind_speed': 23.26, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T14:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 37.0, + 'wind_bearing': 324.49, + 'wind_speed': 21.17, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T15:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 40.0, + 'wind_bearing': 310.68, + 'wind_speed': 19.98, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-03-08T16:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 42.4, + 'wind_bearing': 304.18, + 'wind_speed': 19.66, + }), + ]), + }) +# --- +# name: test_v4_forecast_service[get_forecasts] + dict({ + 'weather.tomorrow_io_daily': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T11:00:00+00:00', + 'dew_point': 12.8, + 'humidity': 58, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.9, + 'templow': 26.1, + 'wind_bearing': 239.6, + 'wind_speed': 34.16, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 49.4, + 'templow': 26.3, + 'wind_bearing': 262.82, + 'wind_speed': 26.06, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-09T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 67.0, + 'templow': 31.5, + 'wind_bearing': 229.3, + 'wind_speed': 25.38, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-10T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 65.3, + 'templow': 37.3, + 'wind_bearing': 149.91, + 'wind_speed': 38.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-11T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 66.2, + 'templow': 48.3, + 'wind_bearing': 210.45, + 'wind_speed': 56.48, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-03-12T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 67.9, + 'templow': 53.8, + 'wind_bearing': 217.98, + 'wind_speed': 44.28, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-13T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 54.5, + 'templow': 42.9, + 'wind_bearing': 58.79, + 'wind_speed': 34.99, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-14T10:00:00+00:00', + 'precipitation': 0.94, + 'precipitation_probability': 95, + 'temperature': 42.9, + 'templow': 33.4, + 'wind_bearing': 70.25, + 'wind_speed': 58.5, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-15T10:00:00+00:00', + 'precipitation': 0.06, + 'precipitation_probability': 55, + 'temperature': 43.7, + 'templow': 29.4, + 'wind_bearing': 84.47, + 'wind_speed': 57.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-16T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 43.0, + 'templow': 29.1, + 'wind_bearing': 103.85, + 'wind_speed': 24.16, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-17T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 52.4, + 'templow': 34.3, + 'wind_bearing': 145.41, + 'wind_speed': 26.17, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-18T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 54.1, + 'templow': 41.3, + 'wind_bearing': 62.99, + 'wind_speed': 23.69, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-03-19T10:00:00+00:00', + 'precipitation': 0.12, + 'precipitation_probability': 55, + 'temperature': 48.9, + 'templow': 39.4, + 'wind_bearing': 68.54, + 'wind_speed': 50.08, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-20T10:00:00+00:00', + 'precipitation': 0.05, + 'precipitation_probability': 33, + 'temperature': 40.1, + 'templow': 35.1, + 'wind_bearing': 56.98, + 'wind_speed': 62.46, + }), + ]), + }), + }) +# --- +# name: test_v4_forecast_service[get_forecasts].1 + dict({ + 'weather.tomorrow_io_daily': dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T17:48:00+00:00', + 'dew_point': 12.8, + 'humidity': 58, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.1, + 'wind_bearing': 315.14, + 'wind_speed': 33.59, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T18:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.8, + 'wind_bearing': 321.71, + 'wind_speed': 31.82, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T19:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.8, + 'wind_bearing': 323.38, + 'wind_speed': 32.04, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T20:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.3, + 'wind_bearing': 318.43, + 'wind_speed': 33.73, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T21:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.6, + 'wind_bearing': 320.9, + 'wind_speed': 28.98, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T22:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 41.9, + 'wind_bearing': 322.11, + 'wind_speed': 15.7, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T23:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 38.9, + 'wind_bearing': 295.94, + 'wind_speed': 17.78, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-08T00:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 36.2, + 'wind_bearing': 11.94, + 'wind_speed': 20.12, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-08T01:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 34.3, + 'wind_bearing': 13.68, + 'wind_speed': 20.05, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T02:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 32.9, + 'wind_bearing': 14.93, + 'wind_speed': 19.48, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T03:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 31.9, + 'wind_bearing': 26.07, + 'wind_speed': 16.6, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T04:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 29.2, + 'wind_bearing': 51.27, + 'wind_speed': 9.32, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T05:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 27.4, + 'wind_bearing': 343.25, + 'wind_speed': 11.92, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T06:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.7, + 'wind_bearing': 341.46, + 'wind_speed': 15.37, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T07:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.4, + 'wind_bearing': 322.34, + 'wind_speed': 12.71, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T08:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.1, + 'wind_bearing': 294.69, + 'wind_speed': 13.14, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T09:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 30.1, + 'wind_bearing': 325.32, + 'wind_speed': 11.52, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T10:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 31.0, + 'wind_bearing': 322.27, + 'wind_speed': 10.22, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T11:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 27.2, + 'wind_bearing': 310.14, + 'wind_speed': 20.12, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T12:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 29.2, + 'wind_bearing': 324.8, + 'wind_speed': 25.38, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-03-08T13:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 33.2, + 'wind_bearing': 335.16, + 'wind_speed': 23.26, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T14:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 37.0, + 'wind_bearing': 324.49, + 'wind_speed': 21.17, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T15:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 40.0, + 'wind_bearing': 310.68, + 'wind_speed': 19.98, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-03-08T16:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 42.4, + 'wind_bearing': 304.18, + 'wind_speed': 19.66, + }), + ]), + }), + }) +# --- diff --git a/tests/components/tomorrowio/test_weather.py b/tests/components/tomorrowio/test_weather.py index 863623ee524..e715fccea6b 100644 --- a/tests/components/tomorrowio/test_weather.py +++ b/tests/components/tomorrowio/test_weather.py @@ -46,7 +46,8 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED_UNIT, DOMAIN as WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + LEGACY_SERVICE_GET_FORECAST, + SERVICE_GET_FORECASTS, ) from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY, SOURCE_USER from homeassistant.const import ATTR_ATTRIBUTION, ATTR_FRIENDLY_NAME, CONF_NAME @@ -277,10 +278,18 @@ async def test_v4_weather_legacy_entities(hass: HomeAssistant) -> None: assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED_UNIT] == "km/h" +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) @freeze_time(datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC)) async def test_v4_forecast_service( hass: HomeAssistant, snapshot: SnapshotAssertion, + service: str, ) -> None: """Test multiple forecast.""" weather_state = await _setup(hass, API_V4_ENTRY_DATA) @@ -289,7 +298,7 @@ async def test_v4_forecast_service( for forecast_type in ("daily", "hourly"): response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": entity_id, "type": forecast_type, @@ -297,10 +306,40 @@ async def test_v4_forecast_service( blocking=True, return_response=True, ) - assert response["forecast"] != [] assert response == snapshot +async def test_legacy_v4_bad_forecast( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + tomorrowio_config_entry_update, + snapshot: SnapshotAssertion, +) -> None: + """Test bad forecast data.""" + freezer.move_to(datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC)) + + weather_state = await _setup(hass, API_V4_ENTRY_DATA) + entity_id = weather_state.entity_id + hourly_forecast = tomorrowio_config_entry_update.return_value["forecasts"]["hourly"] + hourly_forecast[0]["values"]["precipitationProbability"] = "blah" + + # Trigger data refetch + freezer.tick(timedelta(minutes=32) + timedelta(seconds=1)) + await hass.async_block_till_done() + + response = await hass.services.async_call( + WEATHER_DOMAIN, + LEGACY_SERVICE_GET_FORECAST, + { + "entity_id": entity_id, + "type": "hourly", + }, + blocking=True, + return_response=True, + ) + assert response["forecast"][0]["precipitation_probability"] is None + + async def test_v4_bad_forecast( hass: HomeAssistant, freezer: FrozenDateTimeFactory, @@ -321,7 +360,7 @@ async def test_v4_bad_forecast( response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + SERVICE_GET_FORECASTS, { "entity_id": entity_id, "type": "hourly", @@ -329,7 +368,12 @@ async def test_v4_bad_forecast( blocking=True, return_response=True, ) - assert response["forecast"][0]["precipitation_probability"] is None + assert ( + response["weather.tomorrow_io_daily"]["forecast"][0][ + "precipitation_probability" + ] + is None + ) @pytest.mark.parametrize("forecast_type", ["daily", "hourly"]) diff --git a/tests/components/trace/test_websocket_api.py b/tests/components/trace/test_websocket_api.py index 1041208fa61..1197719328b 100644 --- a/tests/components/trace/test_websocket_api.py +++ b/tests/components/trace/test_websocket_api.py @@ -205,7 +205,7 @@ async def test_get_trace( _assert_raw_config(domain, sun_config, trace) assert trace["blueprint_inputs"] is None assert trace["context"] - assert trace["error"] == "Unable to find service test.automation" + assert trace["error"] == "Service test.automation not found." assert trace["state"] == "stopped" assert trace["script_execution"] == "error" assert trace["item_id"] == "sun" @@ -893,7 +893,7 @@ async def test_list_traces( assert len(_find_traces(response["result"], domain, "sun")) == 1 trace = _find_traces(response["result"], domain, "sun")[0] assert trace["last_step"] == last_step[0].format(prefix=prefix) - assert trace["error"] == "Unable to find service test.automation" + assert trace["error"] == "Service test.automation not found." assert trace["state"] == "stopped" assert trace["script_execution"] == script_execution[0] assert trace["timestamp"] @@ -1632,7 +1632,7 @@ async def test_trace_blueprint_automation( assert trace["config"]["id"] == "sun" assert trace["blueprint_inputs"] == sun_config assert trace["context"] - assert trace["error"] == "Unable to find service test.automation" + assert trace["error"] == "Service test.automation not found." assert trace["state"] == "stopped" assert trace["script_execution"] == "error" assert trace["item_id"] == "sun" diff --git a/tests/components/trafikverket_camera/__init__.py b/tests/components/trafikverket_camera/__init__.py index 026c122fb57..a9aa3ad70d1 100644 --- a/tests/components/trafikverket_camera/__init__.py +++ b/tests/components/trafikverket_camera/__init__.py @@ -2,9 +2,14 @@ from __future__ import annotations from homeassistant.components.trafikverket_camera.const import CONF_LOCATION -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_ID ENTRY_CONFIG = { + CONF_API_KEY: "1234567890", + CONF_ID: "1234", +} + +ENTRY_CONFIG_OLD_CONFIG = { CONF_API_KEY: "1234567890", CONF_LOCATION: "Test location", } diff --git a/tests/components/trafikverket_camera/conftest.py b/tests/components/trafikverket_camera/conftest.py index a4902ac2950..a5eeb707b34 100644 --- a/tests/components/trafikverket_camera/conftest.py +++ b/tests/components/trafikverket_camera/conftest.py @@ -32,9 +32,9 @@ async def load_integration_from_entry( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - version=2, + version=3, unique_id="trafikverket_camera-1234", - title="Test location", + title="Test Camera", ) config_entry.add_to_hass(hass) @@ -54,7 +54,7 @@ def fixture_get_camera() -> CameraInfo: """Construct Camera Mock.""" return CameraInfo( - camera_name="Test_camera", + camera_name="Test Camera", camera_id="1234", active=True, deleted=False, diff --git a/tests/components/trafikverket_camera/test_binary_sensor.py b/tests/components/trafikverket_camera/test_binary_sensor.py index 6f7eb540289..87d0e6d58b7 100644 --- a/tests/components/trafikverket_camera/test_binary_sensor.py +++ b/tests/components/trafikverket_camera/test_binary_sensor.py @@ -16,5 +16,5 @@ async def test_sensor( ) -> None: """Test the Trafikverket Camera binary sensor.""" - state = hass.states.get("binary_sensor.test_location_active") + state = hass.states.get("binary_sensor.test_camera_active") assert state.state == STATE_ON diff --git a/tests/components/trafikverket_camera/test_camera.py b/tests/components/trafikverket_camera/test_camera.py index b3df7cfcdcb..182924e9f0e 100644 --- a/tests/components/trafikverket_camera/test_camera.py +++ b/tests/components/trafikverket_camera/test_camera.py @@ -26,7 +26,7 @@ async def test_camera( get_camera: CameraInfo, ) -> None: """Test the Trafikverket Camera sensor.""" - state1 = hass.states.get("camera.test_location") + state1 = hass.states.get("camera.test_camera") assert state1.state == "idle" assert state1.attributes["description"] == "Test Camera for testing" assert state1.attributes["location"] == "Test location" @@ -44,11 +44,11 @@ async def test_camera( async_fire_time_changed(hass) await hass.async_block_till_done() - state1 = hass.states.get("camera.test_location") + state1 = hass.states.get("camera.test_camera") assert state1.state == "idle" assert state1.attributes != {} - assert await async_get_image(hass, "camera.test_location") + assert await async_get_image(hass, "camera.test_camera") monkeypatch.setattr( get_camera, @@ -69,4 +69,4 @@ async def test_camera( await hass.async_block_till_done() with pytest.raises(HomeAssistantError): - await async_get_image(hass, "camera.test_location") + await async_get_image(hass, "camera.test_camera") diff --git a/tests/components/trafikverket_camera/test_config_flow.py b/tests/components/trafikverket_camera/test_config_flow.py index b53763c0ac7..305066832e5 100644 --- a/tests/components/trafikverket_camera/test_config_flow.py +++ b/tests/components/trafikverket_camera/test_config_flow.py @@ -14,7 +14,7 @@ from pytrafikverket.trafikverket_camera import CameraInfo from homeassistant import config_entries from homeassistant.components.trafikverket_camera.const import CONF_LOCATION, DOMAIN -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -47,10 +47,10 @@ async def test_form(hass: HomeAssistant, get_camera: CameraInfo) -> None: await hass.async_block_till_done() assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Test location" + assert result2["title"] == "Test Camera" assert result2["data"] == { "api_key": "1234567890", - "location": "Test location", + "id": "1234", } assert len(mock_setup_entry.mock_calls) == 1 assert result2["result"].unique_id == "trafikverket_camera-1234" @@ -87,7 +87,7 @@ async def test_form_no_location_data( assert result2["title"] == "Test Camera" assert result2["data"] == { "api_key": "1234567890", - "location": "Test Camera", + "id": "1234", } assert len(mock_setup_entry.mock_calls) == 1 assert result2["result"].unique_id == "trafikverket_camera-1234" @@ -150,10 +150,10 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: domain=DOMAIN, data={ CONF_API_KEY: "1234567890", - CONF_LOCATION: "Test location", + CONF_ID: "1234", }, unique_id="1234", - version=2, + version=3, ) entry.add_to_hass(hass) @@ -186,7 +186,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: assert result2["reason"] == "reauth_successful" assert entry.data == { "api_key": "1234567891", - "location": "Test location", + "id": "1234", } @@ -223,10 +223,10 @@ async def test_reauth_flow_error( domain=DOMAIN, data={ CONF_API_KEY: "1234567890", - CONF_LOCATION: "Test location", + CONF_ID: "1234", }, unique_id="1234", - version=2, + version=3, ) entry.add_to_hass(hass) await hass.async_block_till_done() @@ -271,5 +271,5 @@ async def test_reauth_flow_error( assert result2["reason"] == "reauth_successful" assert entry.data == { "api_key": "1234567891", - "location": "Test location", + "id": "1234", } diff --git a/tests/components/trafikverket_camera/test_coordinator.py b/tests/components/trafikverket_camera/test_coordinator.py index 4183aa9fffa..0f79307e0b6 100644 --- a/tests/components/trafikverket_camera/test_coordinator.py +++ b/tests/components/trafikverket_camera/test_coordinator.py @@ -40,9 +40,9 @@ async def test_coordinator( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - version=2, + version=3, unique_id="trafikverket_camera-1234", - title="Test location", + title="Test Camera", ) entry.add_to_hass(hass) @@ -54,7 +54,7 @@ async def test_coordinator( await hass.async_block_till_done() mock_data.assert_called_once() - state1 = hass.states.get("camera.test_location") + state1 = hass.states.get("camera.test_camera") assert state1.state == "idle" @@ -101,9 +101,9 @@ async def test_coordinator_failed_update( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - version=2, + version=3, unique_id="trafikverket_camera-1234", - title="Test location", + title="Test Camera", ) entry.add_to_hass(hass) @@ -115,7 +115,7 @@ async def test_coordinator_failed_update( await hass.async_block_till_done() mock_data.assert_called_once() - state = hass.states.get("camera.test_location") + state = hass.states.get("camera.test_camera") assert state is None assert entry.state == entry_state @@ -135,7 +135,7 @@ async def test_coordinator_failed_get_image( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - version=2, + version=3, unique_id="trafikverket_camera-1234", title="Test location", ) @@ -149,6 +149,6 @@ async def test_coordinator_failed_get_image( await hass.async_block_till_done() mock_data.assert_called_once() - state = hass.states.get("camera.test_location") + state = hass.states.get("camera.test_camera") assert state is None assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY diff --git a/tests/components/trafikverket_camera/test_init.py b/tests/components/trafikverket_camera/test_init.py index 83a3fc1486a..e10c6c16e33 100644 --- a/tests/components/trafikverket_camera/test_init.py +++ b/tests/components/trafikverket_camera/test_init.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import datetime from unittest.mock import patch +import pytest from pytrafikverket.exceptions import UnknownError from pytrafikverket.trafikverket_camera import CameraInfo @@ -14,7 +15,7 @@ from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from . import ENTRY_CONFIG +from . import ENTRY_CONFIG, ENTRY_CONFIG_OLD_CONFIG from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -35,9 +36,9 @@ async def test_setup_entry( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - version=2, + version=3, unique_id="trafikverket_camera-1234", - title="Test location", + title="Test Camera", ) entry.add_to_hass(hass) @@ -67,9 +68,9 @@ async def test_unload_entry( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - version=2, + version=3, unique_id="trafikverket_camera-1234", - title="Test location", + title="Test Camera", ) entry.add_to_hass(hass) @@ -99,7 +100,7 @@ async def test_migrate_entry( entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, - data=ENTRY_CONFIG, + data=ENTRY_CONFIG_OLD_CONFIG, entry_id="1", unique_id="trafikverket_camera-Test location", title="Test location", @@ -114,15 +115,31 @@ async def test_migrate_entry( await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.LOADED - assert entry.version == 2 + assert entry.version == 3 assert entry.unique_id == "trafikverket_camera-1234" - assert len(mock_tvt_camera.mock_calls) == 2 + assert entry.data == ENTRY_CONFIG + assert len(mock_tvt_camera.mock_calls) == 3 +@pytest.mark.parametrize( + ("version", "unique_id"), + [ + ( + 1, + "trafikverket_camera-Test location", + ), + ( + 2, + "trafikverket_camera-1234", + ), + ], +) async def test_migrate_entry_fails_with_error( hass: HomeAssistant, get_camera: CameraInfo, aioclient_mock: AiohttpClientMocker, + version: int, + unique_id: str, ) -> None: """Test migrate entry fails with api error.""" aioclient_mock.get( @@ -132,9 +149,10 @@ async def test_migrate_entry_fails_with_error( entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, - data=ENTRY_CONFIG, + data=ENTRY_CONFIG_OLD_CONFIG, entry_id="1", - unique_id="trafikverket_camera-Test location", + version=version, + unique_id=unique_id, title="Test location", ) entry.add_to_hass(hass) @@ -147,14 +165,29 @@ async def test_migrate_entry_fails_with_error( await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR - assert entry.version == 1 - assert entry.unique_id == "trafikverket_camera-Test location" + assert entry.version == version + assert entry.unique_id == unique_id assert len(mock_tvt_camera.mock_calls) == 1 +@pytest.mark.parametrize( + ("version", "unique_id"), + [ + ( + 1, + "trafikverket_camera-Test location", + ), + ( + 2, + "trafikverket_camera-1234", + ), + ], +) async def test_migrate_entry_fails_no_id( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + version: int, + unique_id: str, ) -> None: """Test migrate entry fails, camera returns no id.""" aioclient_mock.get( @@ -164,9 +197,10 @@ async def test_migrate_entry_fails_no_id( entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, - data=ENTRY_CONFIG, + data=ENTRY_CONFIG_OLD_CONFIG, entry_id="1", - unique_id="trafikverket_camera-Test location", + version=version, + unique_id=unique_id, title="Test location", ) entry.add_to_hass(hass) @@ -195,8 +229,8 @@ async def test_migrate_entry_fails_no_id( await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR - assert entry.version == 1 - assert entry.unique_id == "trafikverket_camera-Test location" + assert entry.version == version + assert entry.unique_id == unique_id assert len(mock_tvt_camera.mock_calls) == 1 @@ -214,7 +248,7 @@ async def test_no_migration_needed( domain=DOMAIN, source=SOURCE_USER, data=ENTRY_CONFIG, - version=2, + version=3, entry_id="1234", unique_id="trafikverket_camera-1234", title="Test location", diff --git a/tests/components/trafikverket_camera/test_recorder.py b/tests/components/trafikverket_camera/test_recorder.py index b9add7ae483..777c6ea26b3 100644 --- a/tests/components/trafikverket_camera/test_recorder.py +++ b/tests/components/trafikverket_camera/test_recorder.py @@ -24,7 +24,7 @@ async def test_exclude_attributes( get_camera: CameraInfo, ) -> None: """Test camera has description and location excluded from recording.""" - state1 = hass.states.get("camera.test_location") + state1 = hass.states.get("camera.test_camera") assert state1.state == "idle" assert state1.attributes["description"] == "Test Camera for testing" assert state1.attributes["location"] == "Test location" @@ -39,10 +39,10 @@ async def test_exclude_attributes( hass.states.async_entity_ids(), ) assert len(states) == 8 - assert states.get("camera.test_location") + assert states.get("camera.test_camera") for entity_states in states.values(): for state in entity_states: - if state.entity_id == "camera.test_location": + if state.entity_id == "camera.test_camera": assert "location" not in state.attributes assert "description" not in state.attributes assert "type" in state.attributes diff --git a/tests/components/trafikverket_camera/test_sensor.py b/tests/components/trafikverket_camera/test_sensor.py index 581fed1d289..c1c98aed797 100644 --- a/tests/components/trafikverket_camera/test_sensor.py +++ b/tests/components/trafikverket_camera/test_sensor.py @@ -15,15 +15,15 @@ async def test_sensor( ) -> None: """Test the Trafikverket Camera sensor.""" - state = hass.states.get("sensor.test_location_direction") + state = hass.states.get("sensor.test_camera_direction") assert state.state == "180" - state = hass.states.get("sensor.test_location_modified") + state = hass.states.get("sensor.test_camera_modified") assert state.state == "2022-04-04T04:04:04+00:00" - state = hass.states.get("sensor.test_location_photo_time") + state = hass.states.get("sensor.test_camera_photo_time") assert state.state == "2022-04-04T04:04:04+00:00" - state = hass.states.get("sensor.test_location_photo_url") + state = hass.states.get("sensor.test_camera_photo_url") assert state.state == "https://www.testurl.com/test_photo.jpg" - state = hass.states.get("sensor.test_location_status") + state = hass.states.get("sensor.test_camera_status") assert state.state == "Running" - state = hass.states.get("sensor.test_location_camera_type") + state = hass.states.get("sensor.test_camera_camera_type") assert state.state == "Road" diff --git a/tests/components/trafikverket_train/test_config_flow.py b/tests/components/trafikverket_train/test_config_flow.py index 3493e031669..1accd4b5a55 100644 --- a/tests/components/trafikverket_train/test_config_flow.py +++ b/tests/components/trafikverket_train/test_config_flow.py @@ -6,7 +6,6 @@ from unittest.mock import patch import pytest from pytrafikverket.exceptions import ( InvalidAuthentication, - MultipleTrainAnnouncementFound, MultipleTrainStationsFound, NoTrainAnnouncementFound, NoTrainStationFound, @@ -177,10 +176,6 @@ async def test_flow_fails( NoTrainAnnouncementFound, "no_trains", ), - ( - MultipleTrainAnnouncementFound, - "multiple_trains", - ), ( UnknownError, "cannot_connect", @@ -371,10 +366,6 @@ async def test_reauth_flow_error( NoTrainAnnouncementFound, "no_trains", ), - ( - MultipleTrainAnnouncementFound, - "multiple_trains", - ), ( UnknownError, "cannot_connect", diff --git a/tests/components/trafikverket_weatherstation/test_config_flow.py b/tests/components/trafikverket_weatherstation/test_config_flow.py index 36c30b33b53..e55e04d8411 100644 --- a/tests/components/trafikverket_weatherstation/test_config_flow.py +++ b/tests/components/trafikverket_weatherstation/test_config_flow.py @@ -15,6 +15,8 @@ from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + DOMAIN = "trafikverket_weatherstation" CONF_STATION = "station" @@ -97,3 +99,103 @@ async def test_flow_fails( ) assert result4["errors"] == {"base": base_error} + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test a reauthentication flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "1234567890", + CONF_STATION: "Vallby", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + assert result["step_id"] == "reauth_confirm" + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.trafikverket_weatherstation.config_flow.TrafikverketWeather.async_get_weather", + ), patch( + "homeassistant.components.trafikverket_weatherstation.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "1234567891"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data == {"api_key": "1234567891", "station": "Vallby"} + + +@pytest.mark.parametrize( + ("side_effect", "base_error"), + [ + ( + InvalidAuthentication, + "invalid_auth", + ), + ( + NoWeatherStationFound, + "invalid_station", + ), + ( + MultipleWeatherStationsFound, + "more_stations", + ), + ( + Exception, + "cannot_connect", + ), + ], +) +async def test_reauth_flow_fails( + hass: HomeAssistant, side_effect: Exception, base_error: str +) -> None: + """Test a reauthentication flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "1234567890", + CONF_STATION: "Vallby", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + assert result["step_id"] == "reauth_confirm" + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.trafikverket_weatherstation.config_flow.TrafikverketWeather.async_get_weather", + side_effect=side_effect(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "1234567891"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": base_error} diff --git a/tests/components/tts/common.py b/tests/components/tts/common.py index a9a95eae2f4..0c3642df6fe 100644 --- a/tests/components/tts/common.py +++ b/tests/components/tts/common.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Generator +from http import HTTPStatus from typing import Any from unittest.mock import MagicMock, patch @@ -32,6 +33,7 @@ from tests.common import ( mock_integration, mock_platform, ) +from tests.typing import ClientSessionGenerator DEFAULT_LANG = "en_US" SUPPORT_LANGUAGES = ["de_CH", "de_DE", "en_GB", "en_US"] @@ -103,6 +105,20 @@ async def get_media_source_url(hass: HomeAssistant, media_content_id: str) -> st return resolved.url +async def retrieve_media( + hass: HomeAssistant, hass_client: ClientSessionGenerator, media_content_id: str +) -> HTTPStatus: + """Get the media source url.""" + url = await get_media_source_url(hass, media_content_id) + + # Ensure media has been generated by requesting it + await hass.async_block_till_done() + client = await hass_client() + req = await client.get(url) + + return req.status + + class BaseProvider: """Test speech API provider.""" diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 2656beba236..71be6b3bb11 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch import pytest -from homeassistant.components import tts +from homeassistant.components import ffmpeg, tts from homeassistant.components.media_player import ( ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_CONTENT_ID, @@ -15,7 +15,6 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, MediaType, ) -from homeassistant.components.media_source import Unresolvable from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State @@ -33,6 +32,7 @@ from .common import ( get_media_source_url, mock_config_entry_setup, mock_setup, + retrieve_media, ) from tests.common import async_mock_service, mock_restore_cache @@ -75,7 +75,9 @@ async def test_default_entity_attributes() -> None: async def test_config_entry_unload( - hass: HomeAssistant, mock_tts_entity: MockTTSEntity + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_tts_entity: MockTTSEntity, ) -> None: """Test we can unload config entry.""" entity_id = f"{tts.DOMAIN}.{TEST_DOMAIN}" @@ -104,7 +106,12 @@ async def test_config_entry_unload( ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media( + hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID] + ) + == HTTPStatus.OK + ) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -1159,6 +1166,7 @@ class MockEntityEmpty(MockTTSEntity): ) async def test_service_get_tts_error( hass: HomeAssistant, + hass_client: ClientSessionGenerator, setup: str, tts_service: str, service_data: dict[str, Any], @@ -1173,8 +1181,10 @@ async def test_service_get_tts_error( blocking=True, ) assert len(calls) == 1 - with pytest.raises(Unresolvable): - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.NOT_FOUND + ) async def test_load_cache_legacy_retrieve_without_mem_cache( @@ -1454,7 +1464,11 @@ async def test_legacy_fetching_in_async( # Test async_get_media_source_audio media_source_id = tts.generate_media_source_id( - hass, "test message", "test", "en_US", None, None + hass, + "test message", + "test", + "en_US", + cache=None, ) task = hass.async_create_task( @@ -1508,16 +1522,6 @@ async def test_fetching_in_async( class EntityWithAsyncFetching(MockTTSEntity): """Entity that supports audio output option.""" - @property - def supported_options(self) -> list[str]: - """Return list of supported options like voice, emotions.""" - return [tts.ATTR_AUDIO_OUTPUT] - - @property - def default_options(self) -> dict[str, str]: - """Return a dict including the default options.""" - return {tts.ATTR_AUDIO_OUTPUT: "mp3"} - async def async_get_tts_audio( self, message: str, language: str, options: dict[str, Any] ) -> tts.TtsAudioType: @@ -1527,7 +1531,11 @@ async def test_fetching_in_async( # Test async_get_media_source_audio media_source_id = tts.generate_media_source_id( - hass, "test message", "tts.test", "en_US", None, None + hass, + "test message", + "tts.test", + "en_US", + cache=None, ) task = hass.async_create_task( @@ -1751,3 +1759,12 @@ async def test_ws_list_voices( {"voice_id": "fran_drescher", "name": "Fran Drescher"}, ] } + + +async def test_async_convert_audio_error(hass: HomeAssistant) -> None: + """Test that ffmpeg failing during audio conversion will raise an error.""" + assert await async_setup_component(hass, ffmpeg.DOMAIN, {}) + + with pytest.raises(RuntimeError): + # Simulate a bad WAV file + await tts.async_convert_audio(hass, "wav", bytes(0), "mp3") diff --git a/tests/components/tts/test_media_source.py b/tests/components/tts/test_media_source.py index 86f1a3bcf3e..641c02064ec 100644 --- a/tests/components/tts/test_media_source.py +++ b/tests/components/tts/test_media_source.py @@ -1,4 +1,5 @@ """Tests for TTS media source.""" +from http import HTTPStatus from unittest.mock import MagicMock import pytest @@ -14,8 +15,11 @@ from .common import ( MockTTSEntity, mock_config_entry_setup, mock_setup, + retrieve_media, ) +from tests.typing import ClientSessionGenerator + class MSEntity(MockTTSEntity): """Test speech API entity.""" @@ -88,16 +92,18 @@ async def test_browsing(hass: HomeAssistant, setup: str) -> None: @pytest.mark.parametrize("mock_provider", [MSProvider(DEFAULT_LANG)]) -async def test_legacy_resolving(hass: HomeAssistant, mock_provider: MSProvider) -> None: +async def test_legacy_resolving( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_provider: MSProvider +) -> None: """Test resolving legacy provider.""" await mock_setup(hass, mock_provider) mock_get_tts_audio = mock_provider.get_tts_audio - media = await media_source.async_resolve_media( - hass, "media-source://tts/test?message=Hello%20World", None - ) + media_id = "media-source://tts/test?message=Hello%20World" + media = await media_source.async_resolve_media(hass, media_id, None) assert media.url.startswith("/api/tts_proxy/") assert media.mime_type == "audio/mpeg" + assert await retrieve_media(hass, hass_client, media_id) == HTTPStatus.OK assert len(mock_get_tts_audio.mock_calls) == 1 message, language = mock_get_tts_audio.mock_calls[0][1] @@ -107,13 +113,11 @@ async def test_legacy_resolving(hass: HomeAssistant, mock_provider: MSProvider) # Pass language and options mock_get_tts_audio.reset_mock() - media = await media_source.async_resolve_media( - hass, - "media-source://tts/test?message=Bye%20World&language=de_DE&voice=Paulus", - None, - ) + media_id = "media-source://tts/test?message=Bye%20World&language=de_DE&voice=Paulus" + media = await media_source.async_resolve_media(hass, media_id, None) assert media.url.startswith("/api/tts_proxy/") assert media.mime_type == "audio/mpeg" + assert await retrieve_media(hass, hass_client, media_id) == HTTPStatus.OK assert len(mock_get_tts_audio.mock_calls) == 1 message, language = mock_get_tts_audio.mock_calls[0][1] @@ -123,16 +127,18 @@ async def test_legacy_resolving(hass: HomeAssistant, mock_provider: MSProvider) @pytest.mark.parametrize("mock_tts_entity", [MSEntity(DEFAULT_LANG)]) -async def test_resolving(hass: HomeAssistant, mock_tts_entity: MSEntity) -> None: +async def test_resolving( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts_entity: MSEntity +) -> None: """Test resolving entity.""" await mock_config_entry_setup(hass, mock_tts_entity) mock_get_tts_audio = mock_tts_entity.get_tts_audio - media = await media_source.async_resolve_media( - hass, "media-source://tts/tts.test?message=Hello%20World", None - ) + media_id = "media-source://tts/tts.test?message=Hello%20World" + media = await media_source.async_resolve_media(hass, media_id, None) assert media.url.startswith("/api/tts_proxy/") assert media.mime_type == "audio/mpeg" + assert await retrieve_media(hass, hass_client, media_id) == HTTPStatus.OK assert len(mock_get_tts_audio.mock_calls) == 1 message, language = mock_get_tts_audio.mock_calls[0][1] @@ -142,13 +148,13 @@ async def test_resolving(hass: HomeAssistant, mock_tts_entity: MSEntity) -> None # Pass language and options mock_get_tts_audio.reset_mock() - media = await media_source.async_resolve_media( - hass, - "media-source://tts/tts.test?message=Bye%20World&language=de_DE&voice=Paulus", - None, + media_id = ( + "media-source://tts/tts.test?message=Bye%20World&language=de_DE&voice=Paulus" ) + media = await media_source.async_resolve_media(hass, media_id, None) assert media.url.startswith("/api/tts_proxy/") assert media.mime_type == "audio/mpeg" + assert await retrieve_media(hass, hass_client, media_id) == HTTPStatus.OK assert len(mock_get_tts_audio.mock_calls) == 1 message, language = mock_get_tts_audio.mock_calls[0][1] diff --git a/tests/components/unifi/test_button.py b/tests/components/unifi/test_button.py index 30a1b3e08ff..8e6dce71160 100644 --- a/tests/components/unifi/test_button.py +++ b/tests/components/unifi/test_button.py @@ -75,3 +75,89 @@ async def test_restart_device_button( # Controller reconnects await websocket_mock.reconnect() assert hass.states.get("button.switch_restart").state != STATE_UNAVAILABLE + + +async def test_power_cycle_poe( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, websocket_mock +) -> None: + """Test restarting device button.""" + config_entry = await setup_unifi_integration( + hass, + aioclient_mock, + devices_response=[ + { + "board_rev": 3, + "device_id": "mock-id", + "ip": "10.0.0.1", + "last_seen": 1562600145, + "mac": "00:00:00:00:01:01", + "model": "US16P150", + "name": "switch", + "state": 1, + "type": "usw", + "version": "4.0.42.10433", + "port_table": [ + { + "media": "GE", + "name": "Port 1", + "port_idx": 1, + "poe_caps": 7, + "poe_class": "Class 4", + "poe_enable": True, + "poe_mode": "auto", + "poe_power": "2.56", + "poe_voltage": "53.40", + "portconf_id": "1a1", + "port_poe": True, + "up": True, + }, + ], + } + ], + ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + + assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 2 + + ent_reg = er.async_get(hass) + ent_reg_entry = ent_reg.async_get("button.switch_port_1_power_cycle") + assert ent_reg_entry.unique_id == "power_cycle-00:00:00:00:01:01_1" + assert ent_reg_entry.entity_category is EntityCategory.CONFIG + + # Validate state object + button = hass.states.get("button.switch_port_1_power_cycle") + assert button is not None + assert button.attributes.get(ATTR_DEVICE_CLASS) == ButtonDeviceClass.RESTART + + # Send restart device command + aioclient_mock.clear_requests() + aioclient_mock.post( + f"https://{controller.host}:1234/api/s/{controller.site}/cmd/devmgr", + ) + + await hass.services.async_call( + BUTTON_DOMAIN, + "press", + {"entity_id": "button.switch_port_1_power_cycle"}, + blocking=True, + ) + assert aioclient_mock.call_count == 1 + assert aioclient_mock.mock_calls[0][2] == { + "cmd": "power-cycle", + "mac": "00:00:00:00:01:01", + "port_idx": 1, + } + + # Availability signalling + + # Controller disconnects + await websocket_mock.disconnect() + assert ( + hass.states.get("button.switch_port_1_power_cycle").state == STATE_UNAVAILABLE + ) + + # Controller reconnects + await websocket_mock.reconnect() + assert ( + hass.states.get("button.switch_port_1_power_cycle").state != STATE_UNAVAILABLE + ) diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index 9d4bde2d016..268f4e8493a 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -167,6 +167,11 @@ def mock_default_unifi_requests( json={"data": wlans_response or [], "meta": {"rc": "ok"}}, headers={"content-type": CONTENT_TYPE_JSON}, ) + aioclient_mock.get( + f"https://{host}:1234/v2/api/site/{site_id}/trafficroutes", + json=[{}], + headers={"content-type": CONTENT_TYPE_JSON}, + ) aioclient_mock.get( f"https://{host}:1234/v2/api/site/{site_id}/trafficrules", json=[{}], @@ -460,6 +465,7 @@ async def test_get_unifi_controller_verify_ssl_false(hass: HomeAssistant) -> Non (aiounifi.RequestError, CannotConnect), (aiounifi.ResponseError, CannotConnect), (aiounifi.Unauthorized, AuthenticationRequired), + (aiounifi.Forbidden, AuthenticationRequired), (aiounifi.LoginRequired, AuthenticationRequired), (aiounifi.AiounifiException, AuthenticationRequired), ], diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index cbff868d9a6..abe12a1e243 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -939,13 +939,20 @@ async def test_restoring_client( ) registry = er.async_get(hass) - registry.async_get_or_create( + registry.async_get_or_create( # Unique ID updated TRACKER_DOMAIN, UNIFI_DOMAIN, f'{restored["mac"]}-site_id', suggested_object_id=restored["hostname"], config_entry=config_entry, ) + registry.async_get_or_create( # Unique ID already updated + TRACKER_DOMAIN, + UNIFI_DOMAIN, + f'site_id-{client["mac"]}', + suggested_object_id=client["hostname"], + config_entry=config_entry, + ) await setup_unifi_integration( hass, diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index cfcfbe6c3ed..00ebcd0e683 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -5,6 +5,7 @@ from datetime import timedelta from aiounifi.models.message import MessageKey import pytest +from homeassistant import config_entries from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -32,7 +33,12 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.util import dt as dt_util -from .test_controller import CONTROLLER_HOST, SITE, setup_unifi_integration +from .test_controller import ( + CONTROLLER_HOST, + ENTRY_CONFIG, + SITE, + setup_unifi_integration, +) from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -771,7 +777,6 @@ async def test_no_clients( }, ) - assert aioclient_mock.call_count == 12 assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 @@ -852,7 +857,7 @@ async def test_switches( assert ent_reg.async_get(entry_id).entity_category is EntityCategory.CONFIG # Block and unblock client - + aioclient_mock.clear_requests() aioclient_mock.post( f"https://{controller.host}:1234/api/s/{controller.site}/cmd/stamgr", ) @@ -860,8 +865,8 @@ async def test_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 13 - assert aioclient_mock.mock_calls[12][2] == { + assert aioclient_mock.call_count == 1 + assert aioclient_mock.mock_calls[0][2] == { "mac": "00:00:00:00:01:01", "cmd": "block-sta", } @@ -869,14 +874,14 @@ async def test_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_on", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 14 - assert aioclient_mock.mock_calls[13][2] == { + assert aioclient_mock.call_count == 2 + assert aioclient_mock.mock_calls[1][2] == { "mac": "00:00:00:00:01:01", "cmd": "unblock-sta", } # Enable and disable DPI - + aioclient_mock.clear_requests() aioclient_mock.put( f"https://{controller.host}:1234/api/s/{controller.site}/rest/dpiapp/5f976f62e3c58f018ec7e17d", ) @@ -887,8 +892,8 @@ async def test_switches( {"entity_id": "switch.block_media_streaming"}, blocking=True, ) - assert aioclient_mock.call_count == 15 - assert aioclient_mock.mock_calls[14][2] == {"enabled": False} + assert aioclient_mock.call_count == 1 + assert aioclient_mock.mock_calls[0][2] == {"enabled": False} await hass.services.async_call( SWITCH_DOMAIN, @@ -896,8 +901,8 @@ async def test_switches( {"entity_id": "switch.block_media_streaming"}, blocking=True, ) - assert aioclient_mock.call_count == 16 - assert aioclient_mock.mock_calls[15][2] == {"enabled": True} + assert aioclient_mock.call_count == 2 + assert aioclient_mock.mock_calls[1][2] == {"enabled": True} async def test_remove_switches( @@ -976,6 +981,7 @@ async def test_block_switches( assert blocked is not None assert blocked.state == "off" + aioclient_mock.clear_requests() aioclient_mock.post( f"https://{controller.host}:1234/api/s/{controller.site}/cmd/stamgr", ) @@ -983,8 +989,8 @@ async def test_block_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 13 - assert aioclient_mock.mock_calls[12][2] == { + assert aioclient_mock.call_count == 1 + assert aioclient_mock.mock_calls[0][2] == { "mac": "00:00:00:00:01:01", "cmd": "block-sta", } @@ -992,8 +998,8 @@ async def test_block_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_on", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 14 - assert aioclient_mock.mock_calls[13][2] == { + assert aioclient_mock.call_count == 2 + assert aioclient_mock.mock_calls[1][2] == { "mac": "00:00:00:00:01:01", "cmd": "unblock-sta", } @@ -1585,3 +1591,70 @@ async def test_port_forwarding_switches( mock_unifi_websocket(message=MessageKey.PORT_FORWARD_DELETED, data=_data) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 + + +async def test_updating_unique_id( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Verify outlet control and poe control unique ID update works.""" + poe_device = { + "board_rev": 3, + "device_id": "mock-id", + "ip": "10.0.0.1", + "last_seen": 1562600145, + "mac": "00:00:00:00:01:01", + "model": "US16P150", + "name": "switch", + "state": 1, + "type": "usw", + "version": "4.0.42.10433", + "port_table": [ + { + "media": "GE", + "name": "Port 1", + "port_idx": 1, + "poe_caps": 7, + "poe_class": "Class 4", + "poe_enable": True, + "poe_mode": "auto", + "poe_power": "2.56", + "poe_voltage": "53.40", + "portconf_id": "1a1", + "port_poe": True, + "up": True, + }, + ], + } + + config_entry = config_entries.ConfigEntry( + version=1, + domain=UNIFI_DOMAIN, + title="Mock Title", + data=ENTRY_CONFIG, + source="test", + options={}, + entry_id="1", + ) + + registry = er.async_get(hass) + registry.async_get_or_create( + SWITCH_DOMAIN, + UNIFI_DOMAIN, + f'{poe_device["mac"]}-poe-1', + suggested_object_id="switch_port_1_poe", + config_entry=config_entry, + ) + registry.async_get_or_create( + SWITCH_DOMAIN, + UNIFI_DOMAIN, + f'{OUTLET_UP1["mac"]}-outlet-1', + suggested_object_id="plug_outlet_1", + config_entry=config_entry, + ) + + await setup_unifi_integration( + hass, aioclient_mock, devices_response=[poe_device, OUTLET_UP1] + ) + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2 + assert hass.states.get("switch.switch_port_1_poe") + assert hass.states.get("switch.plug_outlet_1") diff --git a/tests/components/upnp/conftest.py b/tests/components/upnp/conftest.py index 0952b14303d..db166144925 100644 --- a/tests/components/upnp/conftest.py +++ b/tests/components/upnp/conftest.py @@ -1,6 +1,7 @@ """Configuration for SSDP tests.""" from __future__ import annotations +import copy from datetime import datetime from unittest.mock import AsyncMock, MagicMock, PropertyMock, create_autospec, patch from urllib.parse import urlparse @@ -26,6 +27,7 @@ TEST_UDN = "uuid:device" TEST_ST = "urn:schemas-upnp-org:device:InternetGatewayDevice:1" TEST_USN = f"{TEST_UDN}::{TEST_ST}" TEST_LOCATION = "http://192.168.1.1/desc.xml" +TEST_LOCATION6 = "http://[fe80::1%2]/desc.xml" TEST_HOST = urlparse(TEST_LOCATION).hostname TEST_FRIENDLY_NAME = "mock-name" TEST_MAC_ADDRESS = "00:11:22:33:44:55" @@ -48,11 +50,23 @@ TEST_DISCOVERY = ssdp.SsdpServiceInfo( ssdp_headers={ "_host": TEST_HOST, }, + ssdp_all_locations={ + TEST_LOCATION, + }, ) +@pytest.fixture +def mock_async_create_device(): + """Mock async_upnp_client create device.""" + with patch( + "homeassistant.components.upnp.device.UpnpFactory.async_create_device" + ) as mock_create: + yield mock_create + + @pytest.fixture(autouse=True) -def mock_igd_device() -> IgdDevice: +def mock_igd_device(mock_async_create_device) -> IgdDevice: """Mock async_upnp_client device.""" mock_upnp_device = create_autospec(UpnpDevice, instance=True) mock_upnp_device.device_url = TEST_DISCOVERY.ssdp_location @@ -85,8 +99,6 @@ def mock_igd_device() -> IgdDevice: ) with patch( - "homeassistant.components.upnp.device.UpnpFactory.async_create_device" - ), patch( "homeassistant.components.upnp.device.IgdDevice.__new__", return_value=mock_igd_device, ): @@ -131,16 +143,16 @@ async def silent_ssdp_scanner(hass): ), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch( "homeassistant.components.ssdp.Scanner.async_scan" ), patch( - "homeassistant.components.ssdp.Server._async_start_upnp_servers" + "homeassistant.components.ssdp.Server._async_start_upnp_servers", ), patch( - "homeassistant.components.ssdp.Server._async_stop_upnp_servers" + "homeassistant.components.ssdp.Server._async_stop_upnp_servers", ): yield @pytest.fixture async def ssdp_instant_discovery(): - """Instance discovery.""" + """Instant discovery.""" # Set up device discovery callback. async def register_callback(hass, callback, match_dict): @@ -158,6 +170,30 @@ async def ssdp_instant_discovery(): yield (mock_register, mock_get_info) +@pytest.fixture +async def ssdp_instant_discovery_multi_location(): + """Instant discovery.""" + + test_discovery = copy.deepcopy(TEST_DISCOVERY) + test_discovery.ssdp_location = TEST_LOCATION6 # "Default" location is IPv6. + test_discovery.ssdp_all_locations = {TEST_LOCATION6, TEST_LOCATION} + + # Set up device discovery callback. + async def register_callback(hass, callback, match_dict): + """Immediately do callback.""" + await callback(test_discovery, ssdp.SsdpChange.ALIVE) + return MagicMock() + + with patch( + "homeassistant.components.ssdp.async_register_callback", + side_effect=register_callback, + ) as mock_register, patch( + "homeassistant.components.ssdp.async_get_discovery_info_by_st", + return_value=[test_discovery], + ) as mock_get_info: + yield (mock_register, mock_get_info) + + @pytest.fixture async def ssdp_no_discovery(): """No discovery.""" @@ -197,6 +233,8 @@ async def mock_config_entry( CONFIG_ENTRY_MAC_ADDRESS: TEST_MAC_ADDRESS, }, ) + + # Store igd_device for binary_sensor/sensor tests. entry.igd_device = mock_igd_device # Load config_entry. diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py index 4c69b6f6875..7c542e33c9d 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -134,6 +134,7 @@ async def test_flow_ssdp_non_igd_device(hass: HomeAssistant) -> None: ssdp_usn=TEST_USN, ssdp_st=TEST_ST, ssdp_location=TEST_LOCATION, + ssdp_all_locations=[TEST_LOCATION], upnp={ ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:WFADevice:1", # Non-IGD ssdp.ATTR_UPNP_UDN: TEST_UDN, @@ -324,6 +325,7 @@ async def test_flow_ssdp_discovery_changed_location(hass: HomeAssistant) -> None new_location = TEST_DISCOVERY.ssdp_location + "2" new_discovery = deepcopy(TEST_DISCOVERY) new_discovery.ssdp_location = new_location + new_discovery.ssdp_all_locations = {new_location} result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, diff --git a/tests/components/upnp/test_init.py b/tests/components/upnp/test_init.py index e775757cb1f..d1d3dfa6c35 100644 --- a/tests/components/upnp/test_init.py +++ b/tests/components/upnp/test_init.py @@ -1,6 +1,8 @@ """Test UPnP/IGD setup process.""" from __future__ import annotations +from unittest.mock import AsyncMock + import pytest from homeassistant.components.upnp.const import ( @@ -60,3 +62,35 @@ async def test_async_setup_entry_default_no_mac_address(hass: HomeAssistant) -> # Load config_entry. entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) is True + + +@pytest.mark.usefixtures( + "ssdp_instant_discovery_multi_location", + "mock_get_source_ip", + "mock_mac_address_from_host", +) +async def test_async_setup_entry_multi_location( + hass: HomeAssistant, mock_async_create_device: AsyncMock +) -> None: + """Test async_setup_entry for a device both seen via IPv4 and IPv6. + + The resulting IPv4 location is preferred/stored. + """ + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_USN, + data={ + CONFIG_ENTRY_ST: TEST_ST, + CONFIG_ENTRY_UDN: TEST_UDN, + CONFIG_ENTRY_ORIGINAL_UDN: TEST_UDN, + CONFIG_ENTRY_LOCATION: TEST_LOCATION, + CONFIG_ENTRY_MAC_ADDRESS: TEST_MAC_ADDRESS, + }, + ) + + # Load config_entry. + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) is True + + # Ensure that the IPv4 location is used. + mock_async_create_device.assert_called_once_with(TEST_LOCATION) diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index e7c878b6f40..a1637f62b01 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -94,9 +94,7 @@ async def test_observer_discovery( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -145,9 +143,7 @@ async def test_removal_by_observer_before_started( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch( - "pyudev.MonitorObserver", new=_create_mock_monitor_observer - ), patch.object( + ), patch("pyudev.MonitorObserver", new=_create_mock_monitor_observer), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -184,9 +180,7 @@ async def test_discovered_by_websocket_scan( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -224,9 +218,7 @@ async def test_discovered_by_websocket_scan_limited_by_description_matcher( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -265,9 +257,7 @@ async def test_most_targeted_matcher_wins( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -305,9 +295,7 @@ async def test_discovered_by_websocket_scan_rejected_by_description_matcher( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -349,9 +337,7 @@ async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -389,9 +375,7 @@ async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -433,9 +417,7 @@ async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -478,9 +460,7 @@ async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -517,9 +497,7 @@ async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -554,9 +532,7 @@ async def test_discovered_by_websocket_scan_match_vid_only( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -592,9 +568,7 @@ async def test_discovered_by_websocket_scan_match_vid_wrong_pid( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -629,9 +603,7 @@ async def test_discovered_by_websocket_no_vid_pid( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -667,9 +639,7 @@ async def test_non_matching_discovered_by_scanner_after_started( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -708,9 +678,7 @@ async def test_observer_on_wsl_fallback_without_throwing_exception( "pyudev.Monitor.filter_by", side_effect=ValueError ), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -760,9 +728,7 @@ async def test_not_discovered_by_observer_before_started_on_docker( "homeassistant.components.usb.async_get_usb", return_value=new_usb ), patch( "homeassistant.components.usb.comports", return_value=mock_comports - ), patch( - "pyudev.MonitorObserver", new=_create_mock_monitor_observer - ): + ), patch("pyudev.MonitorObserver", new=_create_mock_monitor_observer): assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() @@ -1047,9 +1013,7 @@ async def test_resolve_serial_by_id( ), patch( "homeassistant.components.usb.get_serial_by_id", return_value="/dev/serial/by-id/bla", - ), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) diff --git a/tests/components/v2c/__init__.py b/tests/components/v2c/__init__.py new file mode 100644 index 00000000000..fdb29e58644 --- /dev/null +++ b/tests/components/v2c/__init__.py @@ -0,0 +1 @@ +"""Tests for the V2C integration.""" diff --git a/tests/components/v2c/conftest.py b/tests/components/v2c/conftest.py new file mode 100644 index 00000000000..85831b607b7 --- /dev/null +++ b/tests/components/v2c/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the V2C tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.v2c.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/v2c/test_config_flow.py b/tests/components/v2c/test_config_flow.py new file mode 100644 index 00000000000..50bc4ca91bf --- /dev/null +++ b/tests/components/v2c/test_config_flow.py @@ -0,0 +1,86 @@ +"""Test the V2C config flow.""" +from unittest.mock import AsyncMock, patch + +import pytest +from pytrydan.exceptions import TrydanError + +from homeassistant import config_entries +from homeassistant.components.v2c.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "pytrydan.Trydan.get_data", + return_value={}, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "EVSE 1.1.1.1" + assert result2["data"] == { + "host": "1.1.1.1", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (TrydanError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock, side_effect: Exception, error: str +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pytrydan.Trydan.get_data", + side_effect=side_effect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": error} + + with patch( + "pytrydan.Trydan.get_data", + return_value={}, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "EVSE 1.1.1.1" + assert result3["data"] == { + "host": "1.1.1.1", + } diff --git a/tests/components/vallox/test_fan.py b/tests/components/vallox/test_fan.py index eb60a3d025d..12b24f46aba 100644 --- a/tests/components/vallox/test_fan.py +++ b/tests/components/vallox/test_fan.py @@ -10,6 +10,7 @@ from homeassistant.components.fan import ( DOMAIN as FAN_DOMAIN, SERVICE_SET_PERCENTAGE, SERVICE_SET_PRESET_MODE, + NotValidPresetModeError, ) from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant @@ -179,7 +180,7 @@ async def test_set_invalid_preset_mode( """Test set preset mode.""" await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() - with pytest.raises(ValueError): + with pytest.raises(NotValidPresetModeError) as exc: await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PRESET_MODE, @@ -189,6 +190,7 @@ async def test_set_invalid_preset_mode( }, blocking=True, ) + assert exc.value.translation_key == "not_valid_preset_mode" async def test_set_preset_mode_exception( diff --git a/tests/components/vallox/test_switch.py b/tests/components/vallox/test_switch.py index 95232045af1..4739e6c4645 100644 --- a/tests/components/vallox/test_switch.py +++ b/tests/components/vallox/test_switch.py @@ -1,4 +1,6 @@ """Tests for Vallox switch platform.""" +from unittest.mock import patch + import pytest from homeassistant.components.switch.const import DOMAIN as SWITCH_DOMAIN @@ -30,7 +32,9 @@ async def test_switch_entities( metrics = {metric_key: value} # Act - with patch_metrics(metrics=metrics): + with patch_metrics(metrics=metrics), patch( + "homeassistant.components.vallox.Vallox.set_settable_address" + ): await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() @@ -56,7 +60,9 @@ async def test_bypass_lock_switch_entitity_set( ) -> None: """Test bypass lock switch set.""" # Act - with patch_metrics(metrics={}), patch_metrics_set() as metrics_set: + with patch_metrics(metrics={}), patch_metrics_set() as metrics_set, patch( + "homeassistant.components.vallox.Vallox.set_settable_address" + ): await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() await hass.services.async_call( diff --git a/tests/components/vicare/test_config_flow.py b/tests/components/vicare/test_config_flow.py index 7f70c13f0b0..283f06b754d 100644 --- a/tests/components/vicare/test_config_flow.py +++ b/tests/components/vicare/test_config_flow.py @@ -10,7 +10,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import dhcp from homeassistant.components.vicare.const import DOMAIN -from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -93,6 +93,61 @@ async def test_user_create_entry( mock_setup_entry.assert_called_once() +async def test_step_reauth(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test reauth flow.""" + new_password = "ABCD" + new_client_id = "EFGH" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=VALID_CONFIG, + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": config_entry.entry_id}, + data=VALID_CONFIG, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # test PyViCareInvalidConfigurationError + with patch( + f"{MODULE}.config_flow.vicare_login", + side_effect=PyViCareInvalidConfigurationError( + {"error": "foo", "error_description": "bar"} + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: new_password, CONF_CLIENT_ID: new_client_id}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + # test success + with patch( + f"{MODULE}.config_flow.vicare_login", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: new_password, CONF_CLIENT_ID: new_client_id}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert len(hass.config_entries.async_entries()) == 1 + assert ( + hass.config_entries.async_entries()[0].data[CONF_PASSWORD] == new_password + ) + assert ( + hass.config_entries.async_entries()[0].data[CONF_CLIENT_ID] == new_client_id + ) + await hass.async_block_till_done() + + async def test_form_dhcp( hass: HomeAssistant, mock_setup_entry: AsyncMock, snapshot: SnapshotAssertion ) -> None: diff --git a/tests/components/vilfo/test_config_flow.py b/tests/components/vilfo/test_config_flow.py index 0aa59c9271f..b893d2df550 100644 --- a/tests/components/vilfo/test_config_flow.py +++ b/tests/components/vilfo/test_config_flow.py @@ -24,9 +24,7 @@ async def test_form(hass: HomeAssistant) -> None: "vilfo.Client.get_board_information", return_value=None ), patch( "vilfo.Client.resolve_firmware_version", return_value=firmware_version - ), patch( - "vilfo.Client.resolve_mac_address", return_value=mock_mac - ), patch( + ), patch("vilfo.Client.resolve_mac_address", return_value=mock_mac), patch( "homeassistant.components.vilfo.async_setup_entry" ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( @@ -117,9 +115,7 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: return_value=None, ), patch( "vilfo.Client.resolve_firmware_version", return_value=firmware_version - ), patch( - "vilfo.Client.resolve_mac_address", return_value=None - ): + ), patch("vilfo.Client.resolve_mac_address", return_value=None): first_flow_result2 = await hass.config_entries.flow.async_configure( first_flow_result1["flow_id"], {CONF_HOST: "testadmin.vilfo.com", CONF_ACCESS_TOKEN: "test-token"}, @@ -134,9 +130,7 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: return_value=None, ), patch( "vilfo.Client.resolve_firmware_version", return_value=firmware_version - ), patch( - "vilfo.Client.resolve_mac_address", return_value=None - ): + ), patch("vilfo.Client.resolve_mac_address", return_value=None): second_flow_result2 = await hass.config_entries.flow.async_configure( second_flow_result1["flow_id"], {CONF_HOST: "testadmin.vilfo.com", CONF_ACCESS_TOKEN: "test-token"}, @@ -177,9 +171,7 @@ async def test_validate_input_returns_data(hass: HomeAssistant) -> None: "vilfo.Client.get_board_information", return_value=None ), patch( "vilfo.Client.resolve_firmware_version", return_value=firmware_version - ), patch( - "vilfo.Client.resolve_mac_address", return_value=None - ): + ), patch("vilfo.Client.resolve_mac_address", return_value=None): result = await hass.components.vilfo.config_flow.validate_input( hass, data=mock_data ) @@ -193,9 +185,7 @@ async def test_validate_input_returns_data(hass: HomeAssistant) -> None: "vilfo.Client.get_board_information", return_value=None ), patch( "vilfo.Client.resolve_firmware_version", return_value=firmware_version - ), patch( - "vilfo.Client.resolve_mac_address", return_value=mock_mac - ): + ), patch("vilfo.Client.resolve_mac_address", return_value=mock_mac): result2 = await hass.components.vilfo.config_flow.validate_input( hass, data=mock_data ) diff --git a/tests/components/vlc_telnet/test_config_flow.py b/tests/components/vlc_telnet/test_config_flow.py index 91ea5b3e439..a94f290f7e6 100644 --- a/tests/components/vlc_telnet/test_config_flow.py +++ b/tests/components/vlc_telnet/test_config_flow.py @@ -124,7 +124,7 @@ async def test_errors( "homeassistant.components.vlc_telnet.config_flow.Client.login", side_effect=login_side_effect, ), patch( - "homeassistant.components.vlc_telnet.config_flow.Client.disconnect" + "homeassistant.components.vlc_telnet.config_flow.Client.disconnect", ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -219,7 +219,7 @@ async def test_reauth_errors( "homeassistant.components.vlc_telnet.config_flow.Client.login", side_effect=login_side_effect, ), patch( - "homeassistant.components.vlc_telnet.config_flow.Client.disconnect" + "homeassistant.components.vlc_telnet.config_flow.Client.disconnect", ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -316,7 +316,7 @@ async def test_hassio_errors( "homeassistant.components.vlc_telnet.config_flow.Client.login", side_effect=login_side_effect, ), patch( - "homeassistant.components.vlc_telnet.config_flow.Client.disconnect" + "homeassistant.components.vlc_telnet.config_flow.Client.disconnect", ): result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py index 982a14a80f4..00b1ae6e72a 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -24,7 +24,7 @@ async def test_user(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.vodafone_station.async_setup_entry" ) as mock_setup_entry, patch( - "requests.get" + "requests.get", ) as mock_request_get: mock_request_get.return_value.status_code = 200 @@ -90,7 +90,7 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> ), patch( "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", ), patch( - "homeassistant.components.vodafone_station.async_setup_entry" + "homeassistant.components.vodafone_station.async_setup_entry", ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -122,9 +122,9 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", ), patch( - "homeassistant.components.vodafone_station.async_setup_entry" + "homeassistant.components.vodafone_station.async_setup_entry", ), patch( - "requests.get" + "requests.get", ) as mock_request_get: mock_request_get.return_value.status_code = 200 @@ -170,7 +170,7 @@ async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> ), patch( "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", ), patch( - "homeassistant.components.vodafone_station.async_setup_entry" + "homeassistant.components.vodafone_station.async_setup_entry", ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -204,7 +204,7 @@ async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> ), patch( "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", ), patch( - "homeassistant.components.vodafone_station.async_setup_entry" + "homeassistant.components.vodafone_station.async_setup_entry", ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/voicerss/test_tts.py b/tests/components/voicerss/test_tts.py index 57a5b298162..24997c9d459 100644 --- a/tests/components/voicerss/test_tts.py +++ b/tests/components/voicerss/test_tts.py @@ -4,18 +4,19 @@ from http import HTTPStatus import pytest -from homeassistant.components import media_source, tts +from homeassistant.components import tts from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, DOMAIN as DOMAIN_MP, SERVICE_PLAY_MEDIA, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from tests.common import assert_setup_component, async_mock_service +from tests.components.tts.common import retrieve_media from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator URL = "https://api.voicerss.org/" FORM_DATA = { @@ -38,15 +39,6 @@ def mock_tts_cache_dir_autouse(mock_tts_cache_dir): return mock_tts_cache_dir -async def get_media_source_url(hass, media_content_id): - """Get the media source url.""" - if media_source.DOMAIN not in hass.config.components: - assert await async_setup_component(hass, media_source.DOMAIN, {}) - - resolved = await media_source.async_resolve_media(hass, media_content_id, None) - return resolved.url - - async def test_setup_component(hass: HomeAssistant) -> None: """Test setup component.""" config = {tts.DOMAIN: {"platform": "voicerss", "api_key": "1234567xx"}} @@ -66,7 +58,9 @@ async def test_setup_component_without_api_key(hass: HomeAssistant) -> None: async def test_service_say( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test service call say.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -90,14 +84,18 @@ async def test_service_say( await hass.async_block_till_done() assert len(calls) == 1 - url = await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - assert url.endswith(".mp3") + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == FORM_DATA async def test_service_say_german_config( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test service call say with german code in the config.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -128,13 +126,18 @@ async def test_service_say_german_config( await hass.async_block_till_done() assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == form_data async def test_service_say_german_service( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test service call say with german code in the service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -160,13 +163,18 @@ async def test_service_say_german_service( await hass.async_block_till_done() assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == form_data async def test_service_say_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test service call say with http response 400.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -189,14 +197,18 @@ async def test_service_say_error( ) await hass.async_block_till_done() - with pytest.raises(HomeAssistantError): - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.NOT_FOUND + ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == FORM_DATA async def test_service_say_timeout( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test service call say with http timeout.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -219,14 +231,18 @@ async def test_service_say_timeout( ) await hass.async_block_till_done() - with pytest.raises(HomeAssistantError): - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.NOT_FOUND + ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == FORM_DATA async def test_service_say_error_msg( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test service call say with http error api message.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -254,7 +270,9 @@ async def test_service_say_error_msg( ) await hass.async_block_till_done() - with pytest.raises(media_source.Unresolvable): - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.NOT_FOUND + ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == FORM_DATA diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index f82a00087c6..dbb848f3b9d 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -1,7 +1,9 @@ """Test VoIP protocol.""" import asyncio +import io import time from unittest.mock import AsyncMock, Mock, patch +import wave import pytest @@ -14,6 +16,24 @@ _ONE_SECOND = 16000 * 2 # 16Khz 16-bit _MEDIA_ID = "12345" +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir + + +def _empty_wav() -> bytes: + """Return bytes of an empty WAV file.""" + with io.BytesIO() as wav_io: + wav_file: wave.Wave_write = wave.open(wav_io, "wb") + with wav_file: + wav_file.setframerate(16000) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + + return wav_io.getvalue() + + async def test_pipeline( hass: HomeAssistant, voip_device: VoIPDevice, @@ -72,8 +92,7 @@ async def test_pipeline( media_source_id: str, ) -> tuple[str, bytes]: assert media_source_id == _MEDIA_ID - - return ("mp3", b"") + return ("wav", _empty_wav()) with patch( "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", @@ -266,7 +285,7 @@ async def test_tts_timeout( media_source_id: str, ) -> tuple[str, bytes]: # Should time out immediately - return ("raw", bytes(0)) + return ("wav", _empty_wav()) with patch( "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", @@ -305,8 +324,8 @@ async def test_tts_timeout( done.set() - rtp_protocol._async_send_audio = AsyncMock(side_effect=async_send_audio) - rtp_protocol._send_tts = AsyncMock(side_effect=send_tts) + rtp_protocol._async_send_audio = AsyncMock(side_effect=async_send_audio) # type: ignore[method-assign] + rtp_protocol._send_tts = AsyncMock(side_effect=send_tts) # type: ignore[method-assign] # silence rtp_protocol.on_chunk(bytes(_ONE_SECOND)) @@ -320,3 +339,264 @@ async def test_tts_timeout( # Wait for mock pipeline to exhaust the audio stream async with asyncio.timeout(1): await done.wait() + + +async def test_tts_wrong_extension( + hass: HomeAssistant, + voip_device: VoIPDevice, +) -> None: + """Test that TTS will only stream WAV audio.""" + assert await async_setup_component(hass, "voip", {}) + + def is_speech(self, chunk): + """Anything non-zero is speech.""" + return sum(chunk) > 0 + + done = asyncio.Event() + + async def async_pipeline_from_audio_stream(*args, **kwargs): + stt_stream = kwargs["stt_stream"] + event_callback = kwargs["event_callback"] + async for _chunk in stt_stream: + # Stream will end when VAD detects end of "speech" + pass + + # Fake intent result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.INTENT_END, + data={ + "intent_output": { + "conversation_id": "fake-conversation", + } + }, + ) + ) + + # Proceed with media output + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_END, + data={"tts_output": {"media_id": _MEDIA_ID}}, + ) + ) + + async def async_get_media_source_audio( + hass: HomeAssistant, + media_source_id: str, + ) -> tuple[str, bytes]: + # Should fail because it's not "wav" + return ("mp3", b"") + + with patch( + "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", + new=is_speech, + ), patch( + "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), patch( + "homeassistant.components.voip.voip.tts.async_get_media_source_audio", + new=async_get_media_source_audio, + ): + rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( + hass, + hass.config.language, + voip_device, + Context(), + opus_payload_type=123, + ) + rtp_protocol.transport = Mock() + + original_send_tts = rtp_protocol._send_tts + + async def send_tts(*args, **kwargs): + # Call original then end test successfully + with pytest.raises(ValueError): + await original_send_tts(*args, **kwargs) + + done.set() + + rtp_protocol._send_tts = AsyncMock(side_effect=send_tts) # type: ignore[method-assign] + + # silence + rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + + # "speech" + rtp_protocol.on_chunk(bytes([255] * _ONE_SECOND * 2)) + + # silence (assumes relaxed VAD sensitivity) + rtp_protocol.on_chunk(bytes(_ONE_SECOND * 4)) + + # Wait for mock pipeline to exhaust the audio stream + async with asyncio.timeout(1): + await done.wait() + + +async def test_tts_wrong_wav_format( + hass: HomeAssistant, + voip_device: VoIPDevice, +) -> None: + """Test that TTS will only stream WAV audio with a specific format.""" + assert await async_setup_component(hass, "voip", {}) + + def is_speech(self, chunk): + """Anything non-zero is speech.""" + return sum(chunk) > 0 + + done = asyncio.Event() + + async def async_pipeline_from_audio_stream(*args, **kwargs): + stt_stream = kwargs["stt_stream"] + event_callback = kwargs["event_callback"] + async for _chunk in stt_stream: + # Stream will end when VAD detects end of "speech" + pass + + # Fake intent result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.INTENT_END, + data={ + "intent_output": { + "conversation_id": "fake-conversation", + } + }, + ) + ) + + # Proceed with media output + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_END, + data={"tts_output": {"media_id": _MEDIA_ID}}, + ) + ) + + async def async_get_media_source_audio( + hass: HomeAssistant, + media_source_id: str, + ) -> tuple[str, bytes]: + # Should fail because it's not 16Khz, 16-bit mono + with io.BytesIO() as wav_io: + wav_file: wave.Wave_write = wave.open(wav_io, "wb") + with wav_file: + wav_file.setframerate(22050) + wav_file.setsampwidth(2) + wav_file.setnchannels(2) + + return ("wav", wav_io.getvalue()) + + with patch( + "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", + new=is_speech, + ), patch( + "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), patch( + "homeassistant.components.voip.voip.tts.async_get_media_source_audio", + new=async_get_media_source_audio, + ): + rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( + hass, + hass.config.language, + voip_device, + Context(), + opus_payload_type=123, + ) + rtp_protocol.transport = Mock() + + original_send_tts = rtp_protocol._send_tts + + async def send_tts(*args, **kwargs): + # Call original then end test successfully + with pytest.raises(ValueError): + await original_send_tts(*args, **kwargs) + + done.set() + + rtp_protocol._send_tts = AsyncMock(side_effect=send_tts) # type: ignore[method-assign] + + # silence + rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + + # "speech" + rtp_protocol.on_chunk(bytes([255] * _ONE_SECOND * 2)) + + # silence (assumes relaxed VAD sensitivity) + rtp_protocol.on_chunk(bytes(_ONE_SECOND * 4)) + + # Wait for mock pipeline to exhaust the audio stream + async with asyncio.timeout(1): + await done.wait() + + +async def test_empty_tts_output( + hass: HomeAssistant, + voip_device: VoIPDevice, +) -> None: + """Test that TTS will not stream when output is empty.""" + assert await async_setup_component(hass, "voip", {}) + + def is_speech(self, chunk): + """Anything non-zero is speech.""" + return sum(chunk) > 0 + + async def async_pipeline_from_audio_stream(*args, **kwargs): + stt_stream = kwargs["stt_stream"] + event_callback = kwargs["event_callback"] + async for _chunk in stt_stream: + # Stream will end when VAD detects end of "speech" + pass + + # Fake intent result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.INTENT_END, + data={ + "intent_output": { + "conversation_id": "fake-conversation", + } + }, + ) + ) + + # Empty TTS output + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_END, + data={"tts_output": {}}, + ) + ) + + with patch( + "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", + new=is_speech, + ), patch( + "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), patch( + "homeassistant.components.voip.voip.PipelineRtpDatagramProtocol._send_tts", + ) as mock_send_tts: + rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( + hass, + hass.config.language, + voip_device, + Context(), + opus_payload_type=123, + ) + rtp_protocol.transport = Mock() + + # silence + rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + + # "speech" + rtp_protocol.on_chunk(bytes([255] * _ONE_SECOND * 2)) + + # silence (assumes relaxed VAD sensitivity) + rtp_protocol.on_chunk(bytes(_ONE_SECOND * 4)) + + # Wait for mock pipeline to finish + async with asyncio.timeout(1): + await rtp_protocol._tts_done.wait() + + mock_send_tts.assert_not_called() diff --git a/tests/components/wallbox/test_number.py b/tests/components/wallbox/test_number.py index 41ebedc91da..837df4dfd47 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -9,9 +9,9 @@ from homeassistant.components.wallbox.const import ( CHARGER_ENERGY_PRICE_KEY, CHARGER_MAX_CHARGING_CURRENT_KEY, ) -from homeassistant.components.wallbox.coordinator import InvalidAuth from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from . import ( authorisation_response, @@ -43,7 +43,7 @@ async def test_wallbox_number_class( status_code=200, ) state = hass.states.get(MOCK_NUMBER_ENTITY_ID) - assert state.attributes["min"] == 0 + assert state.attributes["min"] == 6 assert state.attributes["max"] == 25 await hass.services.async_call( @@ -186,7 +186,7 @@ async def test_wallbox_number_class_energy_price_auth_error( status_code=403, ) - with pytest.raises(InvalidAuth): + with pytest.raises(ConfigEntryAuthFailed): await hass.services.async_call( "number", SERVICE_SET_VALUE, diff --git a/tests/components/wallbox/test_switch.py b/tests/components/wallbox/test_switch.py index 9418b4d8765..edd85c6ccc7 100644 --- a/tests/components/wallbox/test_switch.py +++ b/tests/components/wallbox/test_switch.py @@ -6,9 +6,9 @@ import requests_mock from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.components.wallbox.const import CHARGER_STATUS_ID_KEY -from homeassistant.components.wallbox.coordinator import InvalidAuth from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from . import authorisation_response, setup_integration from .const import MOCK_SWITCH_ENTITY_ID @@ -120,7 +120,7 @@ async def test_wallbox_switch_class_authentication_error( status_code=403, ) - with pytest.raises(InvalidAuth): + with pytest.raises(ConfigEntryAuthFailed): await hass.services.async_call( "switch", SERVICE_TURN_ON, @@ -129,7 +129,7 @@ async def test_wallbox_switch_class_authentication_error( }, blocking=True, ) - with pytest.raises(InvalidAuth): + with pytest.raises(ConfigEntryAuthFailed): await hass.services.async_call( "switch", SERVICE_TURN_OFF, diff --git a/tests/components/waqi/test_config_flow.py b/tests/components/waqi/test_config_flow.py index 7a95e000d82..ecc7e07158d 100644 --- a/tests/components/waqi/test_config_flow.py +++ b/tests/components/waqi/test_config_flow.py @@ -235,9 +235,9 @@ async def test_error_in_second_step( with patch( "aiowaqi.WAQIClient.authenticate", - ), patch( - "aiowaqi.WAQIClient.get_by_coordinates", side_effect=exception - ), patch("aiowaqi.WAQIClient.get_by_station_number", side_effect=exception): + ), patch("aiowaqi.WAQIClient.get_by_coordinates", side_effect=exception), patch( + "aiowaqi.WAQIClient.get_by_station_number", side_effect=exception + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], payload, diff --git a/tests/components/watttime/conftest.py b/tests/components/watttime/conftest.py index f3c1986fcb0..f636ffefcfb 100644 --- a/tests/components/watttime/conftest.py +++ b/tests/components/watttime/conftest.py @@ -106,9 +106,7 @@ async def setup_watttime_fixture(hass, client, config_auth, config_coordinates): ), patch( "homeassistant.components.watttime.config_flow.Client.async_login", return_value=client, - ), patch( - "homeassistant.components.watttime.PLATFORMS", [] - ): + ), patch("homeassistant.components.watttime.PLATFORMS", []): assert await async_setup_component( hass, DOMAIN, {**config_auth, **config_coordinates} ) diff --git a/tests/components/weather/__init__.py b/tests/components/weather/__init__.py index 91097dfae14..35a818735d0 100644 --- a/tests/components/weather/__init__.py +++ b/tests/components/weather/__init__.py @@ -1,14 +1,71 @@ """The tests for Weather platforms.""" -from homeassistant.components.weather import ATTR_CONDITION_SUNNY -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +from typing import Any +from homeassistant.components.weather import ( + ATTR_CONDITION_SUNNY, + ATTR_FORECAST_CLOUD_COVERAGE, + ATTR_FORECAST_HUMIDITY, + ATTR_FORECAST_NATIVE_APPARENT_TEMP, + ATTR_FORECAST_NATIVE_DEW_POINT, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRESSURE, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, + ATTR_FORECAST_NATIVE_WIND_SPEED, + ATTR_FORECAST_UV_INDEX, + ATTR_FORECAST_WIND_BEARING, + DOMAIN, + Forecast, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_integration, + mock_platform, +) from tests.testing_config.custom_components.test import weather as WeatherPlatform -async def create_entity(hass: HomeAssistant, **kwargs): +class MockWeatherTest(WeatherPlatform.MockWeather): + """Mock weather class.""" + + def __init__(self, **values: Any) -> None: + """Initialize.""" + super().__init__(**values) + self.forecast_list: list[Forecast] | None = [ + { + ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, + ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, + ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, + ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, + ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage, + ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed, + ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, + ATTR_FORECAST_WIND_BEARING: self.wind_bearing, + ATTR_FORECAST_UV_INDEX: self.uv_index, + ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( + "native_precipitation" + ), + ATTR_FORECAST_HUMIDITY: self.humidity, + } + ] + + +async def create_entity( + hass: HomeAssistant, + mock_weather: WeatherPlatform.MockWeather, + manifest_extra: dict[str, Any] | None, + **kwargs, +) -> WeatherPlatform.MockWeather: """Create the weather entity to run tests on.""" kwargs = { "native_temperature": None, @@ -16,17 +73,47 @@ async def create_entity(hass: HomeAssistant, **kwargs): "is_daytime": True, **kwargs, } - platform: WeatherPlatform = getattr(hass.components, "test.weather") - platform.init(empty=True) - platform.ENTITIES.append( - platform.MockWeatherMockForecast( - name="Test", condition=ATTR_CONDITION_SUNNY, **kwargs - ) + + weather_entity = mock_weather( + name="Testing", + entity_id="weather.testing", + condition=ATTR_CONDITION_SUNNY, + **kwargs, ) - entity0 = platform.ENTITIES[0] - assert await async_setup_component( - hass, "weather", {"weather": {"platform": "test"}} + 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_weather_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test weather platform via config entry.""" + async_add_entities([weather_entity]) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=async_setup_entry_init, + partial_manifest=manifest_extra, + ), + built_in=False, ) + mock_platform( + hass, + "test.weather", + MockPlatform(async_setup_entry=async_setup_entry_weather_platform), + ) + + 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() - return entity0 + + return weather_entity diff --git a/tests/components/weather/conftest.py b/tests/components/weather/conftest.py new file mode 100644 index 00000000000..a85b5e85d4b --- /dev/null +++ b/tests/components/weather/conftest.py @@ -0,0 +1,22 @@ +"""Fixtures for Weather platform tests.""" +from collections.abc import Generator + +import pytest + +from homeassistant.config_entries import ConfigFlow +from homeassistant.core import HomeAssistant + +from tests.common import mock_config_flow, mock_platform + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, "test.config_flow") + + with mock_config_flow("test", MockFlow): + yield diff --git a/tests/components/weather/snapshots/test_init.ambr b/tests/components/weather/snapshots/test_init.ambr index 03a2d46c80f..1aa78f6bf35 100644 --- a/tests/components/weather/snapshots/test_init.ambr +++ b/tests/components/weather/snapshots/test_init.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_get_forecast[daily-1] +# name: test_get_forecast[daily-1-get_forecast] dict({ 'forecast': list([ dict({ @@ -12,7 +12,22 @@ ]), }) # --- -# name: test_get_forecast[hourly-2] +# name: test_get_forecast[daily-1-get_forecasts] + dict({ + 'weather.testing': dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': None, + 'temperature': 38.0, + 'templow': 38.0, + 'uv_index': None, + 'wind_bearing': None, + }), + ]), + }), + }) +# --- +# name: test_get_forecast[hourly-2-get_forecast] dict({ 'forecast': list([ dict({ @@ -25,7 +40,22 @@ ]), }) # --- -# name: test_get_forecast[twice_daily-4] +# name: test_get_forecast[hourly-2-get_forecasts] + dict({ + 'weather.testing': dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': None, + 'temperature': 38.0, + 'templow': 38.0, + 'uv_index': None, + 'wind_bearing': None, + }), + ]), + }), + }) +# --- +# name: test_get_forecast[twice_daily-4-get_forecast] dict({ 'forecast': list([ dict({ @@ -39,3 +69,19 @@ ]), }) # --- +# name: test_get_forecast[twice_daily-4-get_forecasts] + dict({ + 'weather.testing': dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': None, + 'is_daytime': True, + 'temperature': 38.0, + 'templow': 38.0, + 'uv_index': None, + 'wind_bearing': None, + }), + ]), + }), + }) +# --- diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index f17edb16f07..3890d6a28d1 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -1,7 +1,5 @@ """The test for weather entity.""" -from collections.abc import Generator from datetime import datetime -from typing import Any import pytest from syrupy.assertion import SnapshotAssertion @@ -10,23 +8,13 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_FORECAST, ATTR_FORECAST_APPARENT_TEMP, - ATTR_FORECAST_CLOUD_COVERAGE, ATTR_FORECAST_DEW_POINT, ATTR_FORECAST_HUMIDITY, - ATTR_FORECAST_NATIVE_APPARENT_TEMP, - ATTR_FORECAST_NATIVE_DEW_POINT, - ATTR_FORECAST_NATIVE_PRECIPITATION, - ATTR_FORECAST_NATIVE_PRESSURE, - ATTR_FORECAST_NATIVE_TEMP, - ATTR_FORECAST_NATIVE_TEMP_LOW, - ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, - ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_PRESSURE, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_UV_INDEX, - ATTR_FORECAST_WIND_BEARING, ATTR_FORECAST_WIND_GUST_SPEED, ATTR_FORECAST_WIND_SPEED, ATTR_WEATHER_APPARENT_TEMPERATURE, @@ -44,8 +32,9 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED_UNIT, DOMAIN, + LEGACY_SERVICE_GET_FORECAST, ROUNDING_PRECISION, - SERVICE_GET_FORECAST, + SERVICE_GET_FORECASTS, Forecast, WeatherEntity, WeatherEntityFeature, @@ -56,7 +45,6 @@ from homeassistant.components.weather.const import ( ATTR_WEATHER_DEW_POINT, ATTR_WEATHER_HUMIDITY, ) -from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ( PRECISION_HALVES, PRECISION_TENTHS, @@ -69,9 +57,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.issue_registry as ir -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( DistanceConverter, @@ -81,20 +67,8 @@ from homeassistant.util.unit_conversion import ( ) from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM -from . import create_entity +from . import MockWeatherTest, create_entity -from tests.common import ( - MockConfigEntry, - MockModule, - MockPlatform, - mock_config_flow, - mock_integration, - mock_platform, -) -from tests.testing_config.custom_components.test import weather as WeatherPlatform -from tests.testing_config.custom_components.test_weather import ( - weather as NewWeatherPlatform, -) from tests.typing import WebSocketGenerator @@ -134,20 +108,6 @@ class MockWeatherEntity(WeatherEntity): ] -class MockWeatherEntityPrecision(WeatherEntity): - """Mock a Weather Entity with precision.""" - - def __init__(self) -> None: - """Initiate Entity.""" - super().__init__() - self._attr_condition = ATTR_CONDITION_SUNNY - self._attr_native_temperature = 20.3 - self._attr_native_apparent_temperature = 25.3 - self._attr_native_dew_point = 2.3 - self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS - self._attr_precision = PRECISION_HALVES - - @pytest.mark.parametrize( "native_unit", (UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS) ) @@ -160,7 +120,7 @@ class MockWeatherEntityPrecision(WeatherEntity): ) async def test_temperature( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, native_unit: str, state_unit: str, unit_system, @@ -179,13 +139,23 @@ async def test_temperature( dew_point_state_value = TemperatureConverter.convert( dew_point_native_value, native_unit, state_unit ) - entity0 = await create_entity( - hass, - native_temperature=native_value, - native_temperature_unit=native_unit, - native_apparent_temperature=apparent_native_value, - native_dew_point=dew_point_native_value, - ) + + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = { + "native_temperature": native_value, + "native_temperature_unit": native_unit, + "native_apparent_temperature": apparent_native_value, + "native_dew_point": dew_point_native_value, + } + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) forecast_daily = state.attributes[ATTR_FORECAST][0] @@ -229,7 +199,7 @@ async def test_temperature( ) async def test_temperature_no_unit( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, native_unit: str, state_unit: str, unit_system, @@ -243,13 +213,22 @@ async def test_temperature_no_unit( dew_point_state_value = dew_point_native_value apparent_temp_state_value = apparent_temp_native_value - entity0 = await create_entity( - hass, - native_temperature=native_value, - native_temperature_unit=native_unit, - native_dew_point=dew_point_native_value, - native_apparent_temperature=apparent_temp_native_value, - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = { + "native_temperature": native_value, + "native_temperature_unit": native_unit, + "native_dew_point": dew_point_native_value, + "native_apparent_temperature": apparent_temp_native_value, + } + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] @@ -284,7 +263,7 @@ async def test_temperature_no_unit( ) async def test_pressure( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, native_unit: str, state_unit: str, unit_system, @@ -294,9 +273,18 @@ async def test_pressure( native_value = 30 state_value = PressureConverter.convert(native_value, native_unit, state_unit) - entity0 = await create_entity( - hass, native_pressure=native_value, native_pressure_unit=native_unit - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = {"native_pressure": native_value, "native_pressure_unit": native_unit} + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) + state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] @@ -314,7 +302,7 @@ async def test_pressure( ) async def test_pressure_no_unit( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, native_unit: str, state_unit: str, unit_system, @@ -324,9 +312,18 @@ async def test_pressure_no_unit( native_value = 30 state_value = native_value - entity0 = await create_entity( - hass, native_pressure=native_value, native_pressure_unit=native_unit - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = {"native_pressure": native_value, "native_pressure_unit": native_unit} + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) + state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] @@ -354,7 +351,7 @@ async def test_pressure_no_unit( ) async def test_wind_speed( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, native_unit: str, state_unit: str, unit_system, @@ -364,9 +361,17 @@ async def test_wind_speed( native_value = 10 state_value = SpeedConverter.convert(native_value, native_unit, state_unit) - entity0 = await create_entity( - hass, native_wind_speed=native_value, native_wind_speed_unit=native_unit - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = {"native_wind_speed": native_value, "native_wind_speed_unit": native_unit} + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] @@ -397,7 +402,7 @@ async def test_wind_speed( ) async def test_wind_gust_speed( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, native_unit: str, state_unit: str, unit_system, @@ -407,9 +412,20 @@ async def test_wind_gust_speed( native_value = 10 state_value = SpeedConverter.convert(native_value, native_unit, state_unit) - entity0 = await create_entity( - hass, native_wind_gust_speed=native_value, native_wind_speed_unit=native_unit - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = { + "native_wind_gust_speed": native_value, + "native_wind_speed_unit": native_unit, + } + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] @@ -433,7 +449,7 @@ async def test_wind_gust_speed( ) async def test_wind_speed_no_unit( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, native_unit: str, state_unit: str, unit_system, @@ -443,9 +459,17 @@ async def test_wind_speed_no_unit( native_value = 10 state_value = native_value - entity0 = await create_entity( - hass, native_wind_speed=native_value, native_wind_speed_unit=native_unit - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = {"native_wind_speed": native_value, "native_wind_speed_unit": native_unit} + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] @@ -469,7 +493,7 @@ async def test_wind_speed_no_unit( ) async def test_visibility( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, native_unit: str, state_unit: str, unit_system, @@ -479,9 +503,17 @@ async def test_visibility( native_value = 10 state_value = DistanceConverter.convert(native_value, native_unit, state_unit) - entity0 = await create_entity( - hass, native_visibility=native_value, native_visibility_unit=native_unit - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = {"native_visibility": native_value, "native_visibility_unit": native_unit} + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) expected = state_value @@ -500,7 +532,7 @@ async def test_visibility( ) async def test_visibility_no_unit( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, native_unit: str, state_unit: str, unit_system, @@ -510,9 +542,17 @@ async def test_visibility_no_unit( native_value = 10 state_value = native_value - entity0 = await create_entity( - hass, native_visibility=native_value, native_visibility_unit=native_unit - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = {"native_visibility": native_value, "native_visibility_unit": native_unit} + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) expected = state_value @@ -531,7 +571,7 @@ async def test_visibility_no_unit( ) async def test_precipitation( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, native_unit: str, state_unit: str, unit_system, @@ -541,9 +581,20 @@ async def test_precipitation( native_value = 30 state_value = DistanceConverter.convert(native_value, native_unit, state_unit) - entity0 = await create_entity( - hass, native_precipitation=native_value, native_precipitation_unit=native_unit - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = { + "native_precipitation": native_value, + "native_precipitation_unit": native_unit, + } + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] @@ -564,7 +615,7 @@ async def test_precipitation( ) async def test_precipitation_no_unit( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, native_unit: str, state_unit: str, unit_system, @@ -574,9 +625,20 @@ async def test_precipitation_no_unit( native_value = 30 state_value = native_value - entity0 = await create_entity( - hass, native_precipitation=native_value, native_precipitation_unit=native_unit - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = { + "native_precipitation": native_value, + "native_precipitation_unit": native_unit, + } + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] @@ -589,7 +651,7 @@ async def test_precipitation_no_unit( async def test_wind_bearing_ozone_and_cloud_coverage_and_uv_index( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, ) -> None: """Test wind bearing, ozone and cloud coverage.""" wind_bearing_value = 180 @@ -597,13 +659,22 @@ async def test_wind_bearing_ozone_and_cloud_coverage_and_uv_index( cloud_coverage = 75 uv_index = 1.2 - entity0 = await create_entity( - hass, - wind_bearing=wind_bearing_value, - ozone=ozone_value, - cloud_coverage=cloud_coverage, - uv_index=uv_index, - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = { + "wind_bearing": wind_bearing_value, + "ozone": ozone_value, + "cloud_coverage": cloud_coverage, + "uv_index": uv_index, + } + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] @@ -616,12 +687,22 @@ async def test_wind_bearing_ozone_and_cloud_coverage_and_uv_index( async def test_humidity( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, ) -> None: """Test humidity.""" humidity_value = 80.2 - entity0 = await create_entity(hass, humidity=humidity_value) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = {"humidity": humidity_value} + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] @@ -631,18 +712,28 @@ async def test_humidity( async def test_none_forecast( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, ) -> None: """Test that conversion with None values succeeds.""" - entity0 = await create_entity( - hass, - native_pressure=None, - native_pressure_unit=UnitOfPressure.INHG, - native_wind_speed=None, - native_wind_speed_unit=UnitOfSpeed.METERS_PER_SECOND, - native_precipitation=None, - native_precipitation_unit=UnitOfLength.MILLIMETERS, - ) + + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = { + "native_pressure": None, + "native_pressure_unit": UnitOfPressure.INHG, + "native_wind_speed": None, + "native_wind_speed_unit": UnitOfSpeed.METERS_PER_SECOND, + "native_precipitation": None, + "native_precipitation_unit": UnitOfLength.MILLIMETERS, + } + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] @@ -652,9 +743,7 @@ async def test_none_forecast( assert forecast.get(ATTR_FORECAST_PRECIPITATION) is None -async def test_custom_units( - hass: HomeAssistant, enable_custom_integrations: None -) -> None: +async def test_custom_units(hass: HomeAssistant, config_flow_fixture: None) -> None: """Test custom unit.""" wind_speed_value = 5 wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND @@ -681,32 +770,30 @@ async def test_custom_units( entity_registry.async_update_entity_options(entry.entity_id, "weather", set_options) await hass.async_block_till_done() - platform: WeatherPlatform = getattr(hass.components, "test.weather") - platform.init(empty=True) - platform.ENTITIES.append( - platform.MockWeatherMockForecast( - name="Test", - condition=ATTR_CONDITION_SUNNY, - native_temperature=temperature_value, - native_temperature_unit=temperature_unit, - native_wind_speed=wind_speed_value, - native_wind_speed_unit=wind_speed_unit, - native_pressure=pressure_value, - native_pressure_unit=pressure_unit, - native_visibility=visibility_value, - native_visibility_unit=visibility_unit, - native_precipitation=precipitation_value, - native_precipitation_unit=precipitation_unit, - is_daytime=True, - unique_id="very_unique", - ) - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" - entity0 = platform.ENTITIES[0] - assert await async_setup_component( - hass, "weather", {"weather": {"platform": "test"}} - ) - await hass.async_block_till_done() + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = { + "native_temperature": temperature_value, + "native_temperature_unit": temperature_unit, + "native_wind_speed": wind_speed_value, + "native_wind_speed_unit": wind_speed_unit, + "native_pressure": pressure_value, + "native_pressure_unit": pressure_unit, + "native_visibility": visibility_value, + "native_visibility_unit": visibility_unit, + "native_precipitation": precipitation_value, + "native_precipitation_unit": precipitation_unit, + "is_daytime": True, + "unique_id": "very_unique", + } + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] @@ -802,36 +889,55 @@ async def test_attr(hass: HomeAssistant) -> None: assert weather._wind_speed_unit == UnitOfSpeed.KILOMETERS_PER_HOUR -async def test_precision_for_temperature(hass: HomeAssistant) -> None: +async def test_precision_for_temperature( + hass: HomeAssistant, + config_flow_fixture: None, +) -> None: """Test the precision for temperature.""" - weather = MockWeatherEntityPrecision() - weather.hass = hass + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" - assert weather.condition == ATTR_CONDITION_SUNNY - assert weather.native_temperature == 20.3 - assert weather.native_dew_point == 2.3 - assert weather._temperature_unit == UnitOfTemperature.CELSIUS - assert weather.precision == PRECISION_HALVES + kwargs = { + "precision": PRECISION_HALVES, + "native_temperature": 23.3, + "native_temperature_unit": UnitOfTemperature.CELSIUS, + "native_dew_point": 2.7, + } - assert weather.state_attributes[ATTR_WEATHER_TEMPERATURE] == 20.5 - assert weather.state_attributes[ATTR_WEATHER_DEW_POINT] == 2.5 + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) + + state = hass.states.get(entity0.entity_id) + + assert state.state == ATTR_CONDITION_SUNNY + assert state.attributes[ATTR_WEATHER_TEMPERATURE] == 23.5 + assert state.attributes[ATTR_WEATHER_DEW_POINT] == 2.5 + assert state.attributes[ATTR_WEATHER_TEMPERATURE_UNIT] == UnitOfTemperature.CELSIUS async def test_forecast_twice_daily_missing_is_daytime( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - enable_custom_integrations: None, + config_flow_fixture: None, ) -> None: """Test forecast_twice_daily missing mandatory attribute is_daytime.""" - entity0 = await create_entity( - hass, - native_temperature=38, - native_temperature_unit=UnitOfTemperature.CELSIUS, - is_daytime=None, - supported_features=WeatherEntityFeature.FORECAST_TWICE_DAILY, - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = { + "native_temperature": 38, + "native_temperature_unit": UnitOfTemperature.CELSIUS, + "is_daytime": None, + "supported_features": WeatherEntityFeature.FORECAST_TWICE_DAILY, + } + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) client = await hass_ws_client(hass) @@ -854,6 +960,13 @@ async def test_forecast_twice_daily_missing_is_daytime( assert msg["type"] == "result" +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) @pytest.mark.parametrize( ("forecast_type", "supported_features"), [ @@ -867,23 +980,42 @@ async def test_forecast_twice_daily_missing_is_daytime( ) async def test_get_forecast( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, forecast_type: str, supported_features: int, snapshot: SnapshotAssertion, + service: str, ) -> None: """Test get forecast service.""" - entity0 = await create_entity( - hass, - native_temperature=38, - native_temperature_unit=UnitOfTemperature.CELSIUS, - supported_features=supported_features, - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the forecast_daily.""" + return self.forecast_list + + async def async_forecast_twice_daily(self) -> list[Forecast] | None: + """Return the forecast_twice_daily.""" + forecast = self.forecast_list[0] + forecast["is_daytime"] = True + return [forecast] + + async def async_forecast_hourly(self) -> list[Forecast] | None: + """Return the forecast_hourly.""" + return self.forecast_list + + kwargs = { + "native_temperature": 38, + "native_temperature_unit": UnitOfTemperature.CELSIUS, + "supported_features": supported_features, + } + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) response = await hass.services.async_call( DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": entity0.entity_id, "type": forecast_type, @@ -894,23 +1026,51 @@ async def test_get_forecast( assert response == snapshot +@pytest.mark.parametrize( + ("service", "expected"), + [ + ( + SERVICE_GET_FORECASTS, + { + "weather.testing": { + "forecast": [], + } + }, + ), + ( + LEGACY_SERVICE_GET_FORECAST, + { + "forecast": [], + }, + ), + ], +) async def test_get_forecast_no_forecast( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, + service: str, + expected: dict[str, list | dict[str, list]], ) -> None: """Test get forecast service.""" - entity0 = await create_entity( - hass, - native_temperature=38, - native_temperature_unit=UnitOfTemperature.CELSIUS, - supported_features=WeatherEntityFeature.FORECAST_DAILY, - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the forecast_daily.""" + return None + + kwargs = { + "native_temperature": 38, + "native_temperature_unit": UnitOfTemperature.CELSIUS, + "supported_features": WeatherEntityFeature.FORECAST_DAILY, + } + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) - entity0.forecast_list = None response = await hass.services.async_call( DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": entity0.entity_id, "type": "daily", @@ -918,11 +1078,16 @@ async def test_get_forecast_no_forecast( blocking=True, return_response=True, ) - assert response == { - "forecast": [], - } + assert response == expected +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) @pytest.mark.parametrize( ("supported_features", "forecast_types"), [ @@ -933,26 +1098,42 @@ async def test_get_forecast_no_forecast( ) async def test_get_forecast_unsupported( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, forecast_types: list[str], supported_features: int, + service: str, ) -> None: """Test get forecast service.""" - entity0 = await create_entity( - hass, - native_temperature=38, - native_temperature_unit=UnitOfTemperature.CELSIUS, - supported_features=supported_features, - ) + class MockWeatherMockForecast(MockWeatherTest): + """Mock weather class with mocked legacy forecast.""" + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the forecast_daily.""" + return self.forecast_list + + async def async_forecast_twice_daily(self) -> list[Forecast] | None: + """Return the forecast_twice_daily.""" + return self.forecast_list + + async def async_forecast_hourly(self) -> list[Forecast] | None: + """Return the forecast_hourly.""" + return self.forecast_list + + kwargs = { + "native_temperature": 38, + "native_temperature_unit": UnitOfTemperature.CELSIUS, + "supported_features": supported_features, + } + weather_entity = await create_entity(hass, MockWeatherMockForecast, None, **kwargs) for forecast_type in forecast_types: with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, - SERVICE_GET_FORECAST, + service, { - "entity_id": entity0.entity_id, + "entity_id": weather_entity.entity_id, "type": forecast_type, }, blocking=True, @@ -960,19 +1141,6 @@ async def test_get_forecast_unsupported( ) -class MockFlow(ConfigFlow): - """Test flow.""" - - -@pytest.fixture -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: - """Mock config flow.""" - mock_platform(hass, "test.config_flow") - - with mock_config_flow("test", MockFlow): - yield - - ISSUE_TRACKER = "https://blablabla.com" @@ -1004,31 +1172,9 @@ async def test_issue_forecast_property_deprecated( ) -> None: """Test the issue is raised on deprecated forecast attributes.""" - class MockWeatherMockLegacyForecastOnly(WeatherPlatform.MockWeather): + class MockWeatherMockLegacyForecastOnly(MockWeatherTest): """Mock weather class with mocked legacy forecast.""" - def __init__(self, **values: Any) -> None: - """Initialize.""" - super().__init__(**values) - self.forecast_list: list[Forecast] | None = [ - { - ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, - ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, - ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, - ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, - ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage, - ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, - ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed, - ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, - ATTR_FORECAST_WIND_BEARING: self.wind_bearing, - ATTR_FORECAST_UV_INDEX: self.uv_index, - ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( - "native_precipitation" - ), - ATTR_FORECAST_HUMIDITY: self.humidity, - } - ] - @property def forecast(self) -> list[Forecast] | None: """Return the forecast.""" @@ -1041,48 +1187,10 @@ async def test_issue_forecast_property_deprecated( "native_temperature": 38, "native_temperature_unit": UnitOfTemperature.CELSIUS, } - weather_entity = MockWeatherMockLegacyForecastOnly( - name="Testing", - entity_id="weather.testing", - condition=ATTR_CONDITION_SUNNY, - **kwargs, + weather_entity = await create_entity( + hass, MockWeatherMockLegacyForecastOnly, manifest_extra, **kwargs ) - 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_weather_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - ) -> None: - """Set up test weather platform via config entry.""" - async_add_entities([weather_entity]) - - mock_integration( - hass, - MockModule( - "test", - async_setup_entry=async_setup_entry_init, - partial_manifest=manifest_extra, - ), - built_in=False, - ) - mock_platform( - hass, - "test.weather", - MockPlatform(async_setup_entry=async_setup_entry_weather_platform), - ) - - 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() - assert weather_entity.state == ATTR_CONDITION_SUNNY issues = ir.async_get(hass) @@ -1105,37 +1213,37 @@ async def test_issue_forecast_property_deprecated( async def test_issue_forecast_attr_deprecated( hass: HomeAssistant, - enable_custom_integrations: None, + issue_registry: ir.IssueRegistry, + config_flow_fixture: None, caplog: pytest.LogCaptureFixture, ) -> None: """Test the issue is raised on deprecated forecast attributes.""" + class MockWeatherMockLegacyForecast(MockWeatherTest): + """Mock weather class with legacy forecast.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + kwargs = { "native_temperature": 38, "native_temperature_unit": UnitOfTemperature.CELSIUS, } - platform: WeatherPlatform = getattr(hass.components, "test.weather") - caplog.clear() - platform.init(empty=True) - weather = platform.MockWeather( - name="Testing", - entity_id="weather.testing", - condition=ATTR_CONDITION_SUNNY, - **kwargs, + + # Fake that the class belongs to a custom integration + MockWeatherMockLegacyForecast.__module__ = "custom_components.test.weather" + + weather_entity = await create_entity( + hass, MockWeatherMockLegacyForecast, None, **kwargs ) - weather._attr_forecast = [] - platform.ENTITIES.append(weather) - entity0 = platform.ENTITIES[0] - assert await async_setup_component( - hass, "weather", {"weather": {"platform": "test", "name": "testing"}} + assert weather_entity.state == ATTR_CONDITION_SUNNY + + issue = issue_registry.async_get_issue( + "weather", "deprecated_weather_forecast_test" ) - await hass.async_block_till_done() - - assert entity0.state == ATTR_CONDITION_SUNNY - - issues = ir.async_get(hass) - issue = issues.async_get_issue("weather", "deprecated_weather_forecast_test") assert issue assert issue.issue_domain == "test" assert issue.issue_id == "deprecated_weather_forecast_test" @@ -1143,7 +1251,7 @@ async def test_issue_forecast_attr_deprecated( assert issue.translation_placeholders == {"platform": "test"} assert ( - "test::MockWeather implements the `forecast` property or " + "test::MockWeatherMockLegacyForecast implements the `forecast` property or " "sets `self._attr_forecast` in a subclass of WeatherEntity, this is deprecated " "and will be unsupported from Home Assistant 2024.3. Please report it to the " "author of the 'test' custom integration" @@ -1152,37 +1260,83 @@ async def test_issue_forecast_attr_deprecated( async def test_issue_forecast_deprecated_no_logging( hass: HomeAssistant, - enable_custom_integrations: None, + config_flow_fixture: None, caplog: pytest.LogCaptureFixture, ) -> None: """Test the no issue is raised on deprecated forecast attributes if new methods exist.""" + class MockWeatherMockForecast(MockWeatherTest): + """Mock weather class with mocked new method and legacy forecast.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the forecast_daily.""" + return self.forecast_list + kwargs = { "native_temperature": 38, "native_temperature_unit": UnitOfTemperature.CELSIUS, } - platform: NewWeatherPlatform = getattr(hass.components, "test_weather.weather") - caplog.clear() - platform.init(empty=True) - platform.ENTITIES.append( - platform.MockWeatherMockForecast( - name="Test", - entity_id="weather.test", - condition=ATTR_CONDITION_SUNNY, - **kwargs, - ) - ) - entity0 = platform.ENTITIES[0] - assert await async_setup_component( - hass, "weather", {"weather": {"platform": "test_weather", "name": "test"}} - ) - await hass.async_block_till_done() + weather_entity = await create_entity(hass, MockWeatherMockForecast, None, **kwargs) - assert entity0.state == ATTR_CONDITION_SUNNY + assert weather_entity.state == ATTR_CONDITION_SUNNY - assert "Setting up weather.test_weather" in caplog.text + assert "Setting up weather.test" in caplog.text assert ( "custom_components.test_weather.weather::weather.test is using a forecast attribute on an instance of WeatherEntity" not in caplog.text ) + + +async def test_issue_deprecated_service_weather_get_forecast( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + config_flow_fixture: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the issue is raised on deprecated service weather.get_forecast.""" + + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the forecast_daily.""" + return self.forecast_list + + kwargs = { + "native_temperature": 38, + "native_temperature_unit": UnitOfTemperature.CELSIUS, + "supported_features": WeatherEntityFeature.FORECAST_DAILY, + } + + entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) + + _ = await hass.services.async_call( + DOMAIN, + LEGACY_SERVICE_GET_FORECAST, + { + "entity_id": entity0.entity_id, + "type": "daily", + }, + blocking=True, + return_response=True, + ) + + issue = issue_registry.async_get_issue( + "weather", "deprecated_service_weather_get_forecast" + ) + assert issue + assert issue.issue_domain == "test" + assert issue.issue_id == "deprecated_service_weather_get_forecast" + assert issue.translation_key == "deprecated_service_weather_get_forecast" + + assert ( + "Detected use of service 'weather.get_forecast'. " + "This is deprecated and will stop working in Home Assistant 2024.6. " + "Use 'weather.get_forecasts' instead which supports multiple entities" + ) in caplog.text diff --git a/tests/components/weather/test_intent.py b/tests/components/weather/test_intent.py new file mode 100644 index 00000000000..1a171da7fae --- /dev/null +++ b/tests/components/weather/test_intent.py @@ -0,0 +1,108 @@ +"""Test weather intents.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.weather import ( + DOMAIN, + WeatherEntity, + intent as weather_intent, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent +from homeassistant.setup import async_setup_component + + +async def test_get_weather(hass: HomeAssistant) -> None: + """Test get weather for first entity and by name.""" + assert await async_setup_component(hass, "weather", {"weather": {}}) + + entity1 = WeatherEntity() + entity1._attr_name = "Weather 1" + entity1.entity_id = "weather.test_1" + + entity2 = WeatherEntity() + entity2._attr_name = "Weather 2" + entity2.entity_id = "weather.test_2" + + await hass.data[DOMAIN].async_add_entities([entity1, entity2]) + + await weather_intent.async_setup_intents(hass) + + # First entity will be chosen + response = await intent.async_handle( + hass, "test", weather_intent.INTENT_GET_WEATHER, {} + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + state = response.matched_states[0] + assert state.entity_id == entity1.entity_id + + # Named entity will be chosen + response = await intent.async_handle( + hass, + "test", + weather_intent.INTENT_GET_WEATHER, + {"name": {"value": "Weather 2"}}, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + state = response.matched_states[0] + assert state.entity_id == entity2.entity_id + + +async def test_get_weather_wrong_name(hass: HomeAssistant) -> None: + """Test get weather with the wrong name.""" + assert await async_setup_component(hass, "weather", {"weather": {}}) + + entity1 = WeatherEntity() + entity1._attr_name = "Weather 1" + entity1.entity_id = "weather.test_1" + + await hass.data[DOMAIN].async_add_entities([entity1]) + + await weather_intent.async_setup_intents(hass) + + # Incorrect name + with pytest.raises(intent.IntentHandleError): + await intent.async_handle( + hass, + "test", + weather_intent.INTENT_GET_WEATHER, + {"name": {"value": "not the right name"}}, + ) + + +async def test_get_weather_no_entities(hass: HomeAssistant) -> None: + """Test get weather with no weather entities.""" + assert await async_setup_component(hass, "weather", {"weather": {}}) + await weather_intent.async_setup_intents(hass) + + # No weather entities + with pytest.raises(intent.IntentHandleError): + await intent.async_handle(hass, "test", weather_intent.INTENT_GET_WEATHER, {}) + + +async def test_get_weather_no_state(hass: HomeAssistant) -> None: + """Test get weather when state is not returned.""" + assert await async_setup_component(hass, "weather", {"weather": {}}) + + entity1 = WeatherEntity() + entity1._attr_name = "Weather 1" + entity1.entity_id = "weather.test_1" + + await hass.data[DOMAIN].async_add_entities([entity1]) + + await weather_intent.async_setup_intents(hass) + + # Success with state + response = await intent.async_handle( + hass, "test", weather_intent.INTENT_GET_WEATHER, {} + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + + # Failure without state + with patch("homeassistant.core.StateMachine.get", return_value=None), pytest.raises( + intent.IntentHandleError + ): + await intent.async_handle(hass, "test", weather_intent.INTENT_GET_WEATHER, {}) diff --git a/tests/components/weather/test_recorder.py b/tests/components/weather/test_recorder.py index 049a38cac1e..6b2ce4b633a 100644 --- a/tests/components/weather/test_recorder.py +++ b/tests/components/weather/test_recorder.py @@ -5,56 +5,43 @@ from datetime import timedelta from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states -from homeassistant.components.weather import ATTR_CONDITION_SUNNY, ATTR_FORECAST +from homeassistant.components.weather import ATTR_FORECAST, Forecast from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM +from . import MockWeatherTest, create_entity + from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done -from tests.testing_config.custom_components.test import weather as WeatherPlatform - - -async def create_entity(hass: HomeAssistant, **kwargs): - """Create the weather entity to run tests on.""" - kwargs = { - "native_temperature": None, - "native_temperature_unit": None, - "is_daytime": True, - **kwargs, - } - platform: WeatherPlatform = getattr(hass.components, "test.weather") - platform.init(empty=True) - platform.ENTITIES.append( - platform.MockWeatherMockForecast( - name="Test", condition=ATTR_CONDITION_SUNNY, **kwargs - ) - ) - - entity0 = platform.ENTITIES[0] - assert await async_setup_component( - hass, "weather", {"weather": {"platform": "test"}} - ) - await hass.async_block_till_done() - return entity0 async def test_exclude_attributes( - recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None + recorder_mock: Recorder, + hass: HomeAssistant, + config_flow_fixture: None, ) -> None: """Test weather attributes to be excluded.""" now = dt_util.utcnow() - entity0 = await create_entity( - hass, - native_temperature=38, - native_temperature_unit=UnitOfTemperature.CELSIUS, - ) + + class MockWeatherMockForecast(MockWeatherTest): + """Mock weather class with mocked legacy forecast.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + kwargs = { + "native_temperature": 38, + "native_temperature_unit": UnitOfTemperature.CELSIUS, + } + weather_entity = await create_entity(hass, MockWeatherMockForecast, None, **kwargs) hass.config.units = METRIC_SYSTEM await hass.async_block_till_done() - state = hass.states.get(entity0.entity_id) + state = hass.states.get(weather_entity.entity_id) assert state.attributes[ATTR_FORECAST] await hass.async_block_till_done() diff --git a/tests/components/weather/test_websocket_api.py b/tests/components/weather/test_websocket_api.py index 4f5223c6f79..4a401d79849 100644 --- a/tests/components/weather/test_websocket_api.py +++ b/tests/components/weather/test_websocket_api.py @@ -1,11 +1,11 @@ """Test the weather websocket API.""" -from homeassistant.components.weather import WeatherEntityFeature +from homeassistant.components.weather import Forecast, WeatherEntityFeature from homeassistant.components.weather.const import DOMAIN from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from . import create_entity +from . import MockWeatherTest, create_entity from tests.typing import WebSocketGenerator @@ -40,16 +40,23 @@ async def test_device_class_units( async def test_subscribe_forecast( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - enable_custom_integrations: None, + config_flow_fixture: None, ) -> None: """Test multiple forecast.""" - entity0 = await create_entity( - hass, - native_temperature=38, - native_temperature_unit=UnitOfTemperature.CELSIUS, - supported_features=WeatherEntityFeature.FORECAST_DAILY, - ) + class MockWeatherMockForecast(MockWeatherTest): + """Mock weather class.""" + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the forecast_daily.""" + return self.forecast_list + + kwargs = { + "native_temperature": 38, + "native_temperature_unit": UnitOfTemperature.CELSIUS, + "supported_features": WeatherEntityFeature.FORECAST_DAILY, + } + weather_entity = await create_entity(hass, MockWeatherMockForecast, None, **kwargs) client = await hass_ws_client(hass) @@ -57,7 +64,7 @@ async def test_subscribe_forecast( { "type": "weather/subscribe_forecast", "forecast_type": "daily", - "entity_id": entity0.entity_id, + "entity_id": weather_entity.entity_id, } ) msg = await client.receive_json() @@ -82,16 +89,16 @@ async def test_subscribe_forecast( ], } - await entity0.async_update_listeners(None) + await weather_entity.async_update_listeners(None) msg = await client.receive_json() assert msg["event"] == forecast - await entity0.async_update_listeners(["daily"]) + await weather_entity.async_update_listeners(["daily"]) msg = await client.receive_json() assert msg["event"] == forecast - entity0.forecast_list = None - await entity0.async_update_listeners(None) + weather_entity.forecast_list = None + await weather_entity.async_update_listeners(None) msg = await client.receive_json() assert msg["event"] == {"type": "daily", "forecast": None} @@ -99,7 +106,6 @@ async def test_subscribe_forecast( async def test_subscribe_forecast_unknown_entity( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - enable_custom_integrations: None, ) -> None: """Test multiple forecast.""" @@ -125,23 +131,25 @@ async def test_subscribe_forecast_unknown_entity( async def test_subscribe_forecast_unsupported( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - enable_custom_integrations: None, + config_flow_fixture: None, ) -> None: """Test multiple forecast.""" - entity0 = await create_entity( - hass, - native_temperature=38, - native_temperature_unit=UnitOfTemperature.CELSIUS, - ) + class MockWeatherMock(MockWeatherTest): + """Mock weather class.""" + kwargs = { + "native_temperature": 38, + "native_temperature_unit": UnitOfTemperature.CELSIUS, + } + weather_entity = await create_entity(hass, MockWeatherMock, None, **kwargs) client = await hass_ws_client(hass) await client.send_json_auto_id( { "type": "weather/subscribe_forecast", "forecast_type": "daily", - "entity_id": entity0.entity_id, + "entity_id": weather_entity.entity_id, } ) msg = await client.receive_json() diff --git a/tests/components/weatherkit/snapshots/test_weather.ambr b/tests/components/weatherkit/snapshots/test_weather.ambr index 63321b5a813..1fbe5389e98 100644 --- a/tests/components/weatherkit/snapshots/test_weather.ambr +++ b/tests/components/weatherkit/snapshots/test_weather.ambr @@ -95,6 +95,298 @@ ]), }) # --- +# name: test_daily_forecast[forecast] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-09-08T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 28.6, + 'templow': 21.2, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-09T15:00:00Z', + 'precipitation': 3.6, + 'precipitation_probability': 45.0, + 'temperature': 30.6, + 'templow': 21.0, + 'uv_index': 6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2023-09-10T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 30.4, + 'templow': 23.1, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-11T15:00:00Z', + 'precipitation': 0.7, + 'precipitation_probability': 47.0, + 'temperature': 30.4, + 'templow': 23.1, + 'uv_index': 5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-12T15:00:00Z', + 'precipitation': 7.7, + 'precipitation_probability': 37.0, + 'temperature': 30.4, + 'templow': 22.1, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-13T15:00:00Z', + 'precipitation': 0.6, + 'precipitation_probability': 45.0, + 'temperature': 31.0, + 'templow': 22.6, + 'uv_index': 6, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 52.0, + 'temperature': 31.5, + 'templow': 22.4, + 'uv_index': 7, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2023-09-15T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 31.8, + 'templow': 23.3, + 'uv_index': 8, + }), + dict({ + 'condition': 'lightning', + 'datetime': '2023-09-16T15:00:00Z', + 'precipitation': 5.3, + 'precipitation_probability': 35.0, + 'temperature': 30.7, + 'templow': 23.2, + 'uv_index': 8, + }), + dict({ + 'condition': 'lightning', + 'datetime': '2023-09-17T15:00:00Z', + 'precipitation': 2.1, + 'precipitation_probability': 49.0, + 'temperature': 28.1, + 'templow': 22.5, + 'uv_index': 6, + }), + ]), + }), + }) +# --- +# name: test_daily_forecast[get_forecast] + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-09-08T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 28.6, + 'templow': 21.2, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-09T15:00:00Z', + 'precipitation': 3.6, + 'precipitation_probability': 45.0, + 'temperature': 30.6, + 'templow': 21.0, + 'uv_index': 6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2023-09-10T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 30.4, + 'templow': 23.1, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-11T15:00:00Z', + 'precipitation': 0.7, + 'precipitation_probability': 47.0, + 'temperature': 30.4, + 'templow': 23.1, + 'uv_index': 5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-12T15:00:00Z', + 'precipitation': 7.7, + 'precipitation_probability': 37.0, + 'temperature': 30.4, + 'templow': 22.1, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-13T15:00:00Z', + 'precipitation': 0.6, + 'precipitation_probability': 45.0, + 'temperature': 31.0, + 'templow': 22.6, + 'uv_index': 6, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 52.0, + 'temperature': 31.5, + 'templow': 22.4, + 'uv_index': 7, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2023-09-15T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 31.8, + 'templow': 23.3, + 'uv_index': 8, + }), + dict({ + 'condition': 'lightning', + 'datetime': '2023-09-16T15:00:00Z', + 'precipitation': 5.3, + 'precipitation_probability': 35.0, + 'temperature': 30.7, + 'templow': 23.2, + 'uv_index': 8, + }), + dict({ + 'condition': 'lightning', + 'datetime': '2023-09-17T15:00:00Z', + 'precipitation': 2.1, + 'precipitation_probability': 49.0, + 'temperature': 28.1, + 'templow': 22.5, + 'uv_index': 6, + }), + ]), + }) +# --- +# name: test_daily_forecast[get_forecasts] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-09-08T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 28.6, + 'templow': 21.2, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-09T15:00:00Z', + 'precipitation': 3.6, + 'precipitation_probability': 45.0, + 'temperature': 30.6, + 'templow': 21.0, + 'uv_index': 6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2023-09-10T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 30.4, + 'templow': 23.1, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-11T15:00:00Z', + 'precipitation': 0.7, + 'precipitation_probability': 47.0, + 'temperature': 30.4, + 'templow': 23.1, + 'uv_index': 5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-12T15:00:00Z', + 'precipitation': 7.7, + 'precipitation_probability': 37.0, + 'temperature': 30.4, + 'templow': 22.1, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-13T15:00:00Z', + 'precipitation': 0.6, + 'precipitation_probability': 45.0, + 'temperature': 31.0, + 'templow': 22.6, + 'uv_index': 6, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 52.0, + 'temperature': 31.5, + 'templow': 22.4, + 'uv_index': 7, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2023-09-15T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 31.8, + 'templow': 23.3, + 'uv_index': 8, + }), + dict({ + 'condition': 'lightning', + 'datetime': '2023-09-16T15:00:00Z', + 'precipitation': 5.3, + 'precipitation_probability': 35.0, + 'temperature': 30.7, + 'templow': 23.2, + 'uv_index': 8, + }), + dict({ + 'condition': 'lightning', + 'datetime': '2023-09-17T15:00:00Z', + 'precipitation': 2.1, + 'precipitation_probability': 49.0, + 'temperature': 28.1, + 'templow': 22.5, + 'uv_index': 6, + }), + ]), + }), + }) +# --- # name: test_hourly_forecast dict({ 'forecast': list([ @@ -4085,3 +4377,11977 @@ ]), }) # --- +# name: test_hourly_forecast[forecast] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 79.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T14:00:00Z', + 'dew_point': 21.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.24, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 264, + 'wind_gust_speed': 13.44, + 'wind_speed': 6.62, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 80.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T15:00:00Z', + 'dew_point': 21.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.24, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 261, + 'wind_gust_speed': 11.91, + 'wind_speed': 6.64, + }), + dict({ + 'apparent_temperature': 23.8, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T16:00:00Z', + 'dew_point': 21.1, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.12, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 252, + 'wind_gust_speed': 11.15, + 'wind_speed': 6.14, + }), + dict({ + 'apparent_temperature': 23.5, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T17:00:00Z', + 'dew_point': 20.9, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.03, + 'temperature': 21.7, + 'uv_index': 0, + 'wind_bearing': 248, + 'wind_gust_speed': 11.57, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 23.3, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T18:00:00Z', + 'dew_point': 20.8, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.05, + 'temperature': 21.6, + 'uv_index': 0, + 'wind_bearing': 237, + 'wind_gust_speed': 12.42, + 'wind_speed': 5.86, + }), + dict({ + 'apparent_temperature': 23.0, + 'cloud_coverage': 75.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T19:00:00Z', + 'dew_point': 20.6, + 'humidity': 96, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.03, + 'temperature': 21.3, + 'uv_index': 0, + 'wind_bearing': 224, + 'wind_gust_speed': 11.3, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 22.8, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T20:00:00Z', + 'dew_point': 20.4, + 'humidity': 96, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.31, + 'temperature': 21.2, + 'uv_index': 0, + 'wind_bearing': 221, + 'wind_gust_speed': 10.57, + 'wind_speed': 5.13, + }), + dict({ + 'apparent_temperature': 23.1, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-08T21:00:00Z', + 'dew_point': 20.5, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.55, + 'temperature': 21.4, + 'uv_index': 0, + 'wind_bearing': 237, + 'wind_gust_speed': 10.63, + 'wind_speed': 5.7, + }), + dict({ + 'apparent_temperature': 24.9, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-08T22:00:00Z', + 'dew_point': 21.3, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.79, + 'temperature': 22.8, + 'uv_index': 1, + 'wind_bearing': 258, + 'wind_gust_speed': 10.47, + 'wind_speed': 5.22, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T23:00:00Z', + 'dew_point': 21.3, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.95, + 'temperature': 24.0, + 'uv_index': 2, + 'wind_bearing': 282, + 'wind_gust_speed': 12.74, + 'wind_speed': 5.71, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T00:00:00Z', + 'dew_point': 21.5, + 'humidity': 80, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.35, + 'temperature': 25.1, + 'uv_index': 3, + 'wind_bearing': 294, + 'wind_gust_speed': 13.87, + 'wind_speed': 6.53, + }), + dict({ + 'apparent_temperature': 29.0, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T01:00:00Z', + 'dew_point': 21.8, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.48, + 'temperature': 26.5, + 'uv_index': 5, + 'wind_bearing': 308, + 'wind_gust_speed': 16.04, + 'wind_speed': 6.54, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T02:00:00Z', + 'dew_point': 22.0, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.23, + 'temperature': 27.6, + 'uv_index': 6, + 'wind_bearing': 314, + 'wind_gust_speed': 18.1, + 'wind_speed': 7.32, + }), + dict({ + 'apparent_temperature': 31.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T03:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.86, + 'temperature': 28.3, + 'uv_index': 6, + 'wind_bearing': 317, + 'wind_gust_speed': 20.77, + 'wind_speed': 9.1, + }), + dict({ + 'apparent_temperature': 31.5, + 'cloud_coverage': 69.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T04:00:00Z', + 'dew_point': 22.1, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.65, + 'temperature': 28.6, + 'uv_index': 6, + 'wind_bearing': 311, + 'wind_gust_speed': 21.27, + 'wind_speed': 10.21, + }), + dict({ + 'apparent_temperature': 31.3, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T05:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.48, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 317, + 'wind_gust_speed': 19.62, + 'wind_speed': 10.53, + }), + dict({ + 'apparent_temperature': 30.8, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T06:00:00Z', + 'dew_point': 22.2, + 'humidity': 71, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.54, + 'temperature': 27.9, + 'uv_index': 3, + 'wind_bearing': 335, + 'wind_gust_speed': 18.98, + 'wind_speed': 8.63, + }), + dict({ + 'apparent_temperature': 29.9, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T07:00:00Z', + 'dew_point': 22.2, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.76, + 'temperature': 27.1, + 'uv_index': 2, + 'wind_bearing': 338, + 'wind_gust_speed': 17.04, + 'wind_speed': 7.75, + }), + dict({ + 'apparent_temperature': 29.1, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T08:00:00Z', + 'dew_point': 22.1, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.05, + 'temperature': 26.4, + 'uv_index': 0, + 'wind_bearing': 342, + 'wind_gust_speed': 14.75, + 'wind_speed': 6.26, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T09:00:00Z', + 'dew_point': 22.0, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.38, + 'temperature': 25.4, + 'uv_index': 0, + 'wind_bearing': 344, + 'wind_gust_speed': 10.43, + 'wind_speed': 5.2, + }), + dict({ + 'apparent_temperature': 26.9, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T10:00:00Z', + 'dew_point': 21.9, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.73, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 339, + 'wind_gust_speed': 6.95, + 'wind_speed': 3.59, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T11:00:00Z', + 'dew_point': 21.8, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.3, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 326, + 'wind_gust_speed': 5.27, + 'wind_speed': 2.1, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 53.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.52, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 257, + 'wind_gust_speed': 5.48, + 'wind_speed': 0.93, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T13:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.53, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 188, + 'wind_gust_speed': 4.44, + 'wind_speed': 1.79, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T14:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.46, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 183, + 'wind_gust_speed': 4.49, + 'wind_speed': 2.19, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T15:00:00Z', + 'dew_point': 21.4, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.21, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 179, + 'wind_gust_speed': 5.32, + 'wind_speed': 2.65, + }), + dict({ + 'apparent_temperature': 24.0, + 'cloud_coverage': 42.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T16:00:00Z', + 'dew_point': 21.1, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.09, + 'temperature': 22.1, + 'uv_index': 0, + 'wind_bearing': 173, + 'wind_gust_speed': 5.81, + 'wind_speed': 3.2, + }), + dict({ + 'apparent_temperature': 23.7, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T17:00:00Z', + 'dew_point': 20.9, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.88, + 'temperature': 21.9, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 5.53, + 'wind_speed': 3.16, + }), + dict({ + 'apparent_temperature': 23.3, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T18:00:00Z', + 'dew_point': 20.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.94, + 'temperature': 21.6, + 'uv_index': 0, + 'wind_bearing': 153, + 'wind_gust_speed': 6.09, + 'wind_speed': 3.36, + }), + dict({ + 'apparent_temperature': 23.1, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T19:00:00Z', + 'dew_point': 20.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.96, + 'temperature': 21.4, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 6.83, + 'wind_speed': 3.71, + }), + dict({ + 'apparent_temperature': 22.5, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T20:00:00Z', + 'dew_point': 20.0, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 21.0, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 7.98, + 'wind_speed': 4.27, + }), + dict({ + 'apparent_temperature': 22.8, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T21:00:00Z', + 'dew_point': 20.2, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.61, + 'temperature': 21.2, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 8.4, + 'wind_speed': 4.69, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T22:00:00Z', + 'dew_point': 21.3, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.87, + 'temperature': 23.1, + 'uv_index': 1, + 'wind_bearing': 150, + 'wind_gust_speed': 7.66, + 'wind_speed': 4.33, + }), + dict({ + 'apparent_temperature': 28.3, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T23:00:00Z', + 'dew_point': 22.3, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 25.6, + 'uv_index': 2, + 'wind_bearing': 123, + 'wind_gust_speed': 9.63, + 'wind_speed': 3.91, + }), + dict({ + 'apparent_temperature': 30.4, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T00:00:00Z', + 'dew_point': 22.6, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 27.4, + 'uv_index': 4, + 'wind_bearing': 105, + 'wind_gust_speed': 12.59, + 'wind_speed': 3.96, + }), + dict({ + 'apparent_temperature': 32.2, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T01:00:00Z', + 'dew_point': 22.9, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.79, + 'temperature': 28.9, + 'uv_index': 5, + 'wind_bearing': 99, + 'wind_gust_speed': 14.17, + 'wind_speed': 4.06, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 62.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-10T02:00:00Z', + 'dew_point': 22.9, + 'humidity': 66, + 'precipitation': 0.3, + 'precipitation_probability': 7.000000000000001, + 'pressure': 1011.29, + 'temperature': 29.9, + 'uv_index': 6, + 'wind_bearing': 93, + 'wind_gust_speed': 17.75, + 'wind_speed': 4.87, + }), + dict({ + 'apparent_temperature': 34.3, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T03:00:00Z', + 'dew_point': 23.1, + 'humidity': 64, + 'precipitation': 0.3, + 'precipitation_probability': 11.0, + 'pressure': 1010.78, + 'temperature': 30.6, + 'uv_index': 6, + 'wind_bearing': 78, + 'wind_gust_speed': 17.43, + 'wind_speed': 4.54, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 74.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T04:00:00Z', + 'dew_point': 23.2, + 'humidity': 66, + 'precipitation': 0.4, + 'precipitation_probability': 15.0, + 'pressure': 1010.37, + 'temperature': 30.3, + 'uv_index': 5, + 'wind_bearing': 60, + 'wind_gust_speed': 15.24, + 'wind_speed': 4.9, + }), + dict({ + 'apparent_temperature': 33.7, + 'cloud_coverage': 79.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T05:00:00Z', + 'dew_point': 23.3, + 'humidity': 67, + 'precipitation': 0.7, + 'precipitation_probability': 17.0, + 'pressure': 1010.09, + 'temperature': 30.0, + 'uv_index': 4, + 'wind_bearing': 80, + 'wind_gust_speed': 13.53, + 'wind_speed': 5.98, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 80.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T06:00:00Z', + 'dew_point': 23.4, + 'humidity': 70, + 'precipitation': 1.0, + 'precipitation_probability': 17.0, + 'pressure': 1010.0, + 'temperature': 29.5, + 'uv_index': 3, + 'wind_bearing': 83, + 'wind_gust_speed': 12.55, + 'wind_speed': 6.84, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 88.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T07:00:00Z', + 'dew_point': 23.4, + 'humidity': 73, + 'precipitation': 0.4, + 'precipitation_probability': 16.0, + 'pressure': 1010.27, + 'temperature': 28.7, + 'uv_index': 2, + 'wind_bearing': 90, + 'wind_gust_speed': 10.16, + 'wind_speed': 6.07, + }), + dict({ + 'apparent_temperature': 30.9, + 'cloud_coverage': 92.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T08:00:00Z', + 'dew_point': 23.2, + 'humidity': 77, + 'precipitation': 0.5, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1010.71, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 101, + 'wind_gust_speed': 8.18, + 'wind_speed': 4.82, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 93.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T09:00:00Z', + 'dew_point': 23.2, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.9, + 'temperature': 26.5, + 'uv_index': 0, + 'wind_bearing': 128, + 'wind_gust_speed': 8.89, + 'wind_speed': 4.95, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T10:00:00Z', + 'dew_point': 23.0, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.12, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 134, + 'wind_gust_speed': 10.03, + 'wind_speed': 4.52, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 87.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T11:00:00Z', + 'dew_point': 22.8, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.43, + 'temperature': 25.1, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 12.4, + 'wind_speed': 5.41, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T12:00:00Z', + 'dew_point': 22.5, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.58, + 'temperature': 24.8, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 16.36, + 'wind_speed': 6.31, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T13:00:00Z', + 'dew_point': 22.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.55, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 19.66, + 'wind_speed': 7.23, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T14:00:00Z', + 'dew_point': 22.2, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.4, + 'temperature': 24.3, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 21.15, + 'wind_speed': 7.46, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T15:00:00Z', + 'dew_point': 22.0, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.23, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 22.26, + 'wind_speed': 7.84, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T16:00:00Z', + 'dew_point': 21.8, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.01, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 23.53, + 'wind_speed': 8.63, + }), + dict({ + 'apparent_temperature': 25.6, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-10T17:00:00Z', + 'dew_point': 21.6, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.78, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 22.83, + 'wind_speed': 8.61, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T18:00:00Z', + 'dew_point': 21.5, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.69, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 23.7, + 'wind_speed': 8.7, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T19:00:00Z', + 'dew_point': 21.4, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.77, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 24.24, + 'wind_speed': 8.74, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T20:00:00Z', + 'dew_point': 21.6, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.89, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 23.99, + 'wind_speed': 8.81, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T21:00:00Z', + 'dew_point': 21.6, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.1, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 25.55, + 'wind_speed': 9.05, + }), + dict({ + 'apparent_temperature': 27.0, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T22:00:00Z', + 'dew_point': 21.8, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 24.6, + 'uv_index': 1, + 'wind_bearing': 140, + 'wind_gust_speed': 29.08, + 'wind_speed': 10.37, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T23:00:00Z', + 'dew_point': 21.9, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.36, + 'temperature': 25.9, + 'uv_index': 2, + 'wind_bearing': 140, + 'wind_gust_speed': 34.13, + 'wind_speed': 12.56, + }), + dict({ + 'apparent_temperature': 30.1, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T00:00:00Z', + 'dew_point': 22.3, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 27.2, + 'uv_index': 3, + 'wind_bearing': 140, + 'wind_gust_speed': 38.2, + 'wind_speed': 15.65, + }), + dict({ + 'apparent_temperature': 31.4, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T01:00:00Z', + 'dew_point': 22.3, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.31, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 37.55, + 'wind_speed': 15.78, + }), + dict({ + 'apparent_temperature': 32.7, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T02:00:00Z', + 'dew_point': 22.4, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.98, + 'temperature': 29.6, + 'uv_index': 6, + 'wind_bearing': 143, + 'wind_gust_speed': 35.86, + 'wind_speed': 15.41, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T03:00:00Z', + 'dew_point': 22.5, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.61, + 'temperature': 30.3, + 'uv_index': 6, + 'wind_bearing': 141, + 'wind_gust_speed': 35.88, + 'wind_speed': 15.51, + }), + dict({ + 'apparent_temperature': 33.8, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T04:00:00Z', + 'dew_point': 22.6, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.36, + 'temperature': 30.4, + 'uv_index': 5, + 'wind_bearing': 140, + 'wind_gust_speed': 35.99, + 'wind_speed': 15.75, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T05:00:00Z', + 'dew_point': 22.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.11, + 'temperature': 30.1, + 'uv_index': 4, + 'wind_bearing': 137, + 'wind_gust_speed': 33.61, + 'wind_speed': 15.36, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 77.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T06:00:00Z', + 'dew_point': 22.5, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.98, + 'temperature': 30.0, + 'uv_index': 3, + 'wind_bearing': 138, + 'wind_gust_speed': 32.61, + 'wind_speed': 14.98, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T07:00:00Z', + 'dew_point': 22.2, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.13, + 'temperature': 29.2, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 28.1, + 'wind_speed': 13.88, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T08:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.48, + 'temperature': 28.3, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 24.22, + 'wind_speed': 13.02, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 55.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T09:00:00Z', + 'dew_point': 21.9, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.81, + 'temperature': 27.1, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 22.5, + 'wind_speed': 11.94, + }), + dict({ + 'apparent_temperature': 28.8, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T10:00:00Z', + 'dew_point': 21.7, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 21.47, + 'wind_speed': 11.25, + }), + dict({ + 'apparent_temperature': 28.1, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T11:00:00Z', + 'dew_point': 21.8, + 'humidity': 80, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.77, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 22.71, + 'wind_speed': 12.39, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.97, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 23.67, + 'wind_speed': 12.83, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T13:00:00Z', + 'dew_point': 21.7, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.97, + 'temperature': 24.7, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 23.34, + 'wind_speed': 12.62, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T14:00:00Z', + 'dew_point': 21.7, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.83, + 'temperature': 24.4, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 22.9, + 'wind_speed': 12.07, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T15:00:00Z', + 'dew_point': 21.6, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.74, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 22.01, + 'wind_speed': 11.19, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T16:00:00Z', + 'dew_point': 21.6, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.56, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 21.29, + 'wind_speed': 10.97, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T17:00:00Z', + 'dew_point': 21.5, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.35, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 20.52, + 'wind_speed': 10.5, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T18:00:00Z', + 'dew_point': 21.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.3, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 20.04, + 'wind_speed': 10.51, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T19:00:00Z', + 'dew_point': 21.3, + 'humidity': 88, + 'precipitation': 0.3, + 'precipitation_probability': 12.0, + 'pressure': 1011.37, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 18.07, + 'wind_speed': 10.13, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T20:00:00Z', + 'dew_point': 21.2, + 'humidity': 89, + 'precipitation': 0.2, + 'precipitation_probability': 13.0, + 'pressure': 1011.53, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 16.86, + 'wind_speed': 10.34, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T21:00:00Z', + 'dew_point': 21.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.71, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 16.66, + 'wind_speed': 10.68, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T22:00:00Z', + 'dew_point': 21.9, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.94, + 'temperature': 24.4, + 'uv_index': 1, + 'wind_bearing': 137, + 'wind_gust_speed': 17.21, + 'wind_speed': 10.61, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T23:00:00Z', + 'dew_point': 22.3, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.05, + 'temperature': 25.6, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 19.23, + 'wind_speed': 11.13, + }), + dict({ + 'apparent_temperature': 29.5, + 'cloud_coverage': 79.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T00:00:00Z', + 'dew_point': 22.6, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.07, + 'temperature': 26.6, + 'uv_index': 3, + 'wind_bearing': 140, + 'wind_gust_speed': 20.61, + 'wind_speed': 11.13, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 82.0, + 'condition': 'rainy', + 'datetime': '2023-09-12T01:00:00Z', + 'dew_point': 23.1, + 'humidity': 75, + 'precipitation': 0.2, + 'precipitation_probability': 16.0, + 'pressure': 1011.89, + 'temperature': 27.9, + 'uv_index': 4, + 'wind_bearing': 141, + 'wind_gust_speed': 23.35, + 'wind_speed': 11.98, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T02:00:00Z', + 'dew_point': 23.5, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.53, + 'temperature': 29.0, + 'uv_index': 5, + 'wind_bearing': 143, + 'wind_gust_speed': 26.45, + 'wind_speed': 13.01, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T03:00:00Z', + 'dew_point': 23.5, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.15, + 'temperature': 29.8, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 28.95, + 'wind_speed': 13.9, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T04:00:00Z', + 'dew_point': 23.4, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.79, + 'temperature': 30.2, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 27.9, + 'wind_speed': 13.95, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T05:00:00Z', + 'dew_point': 23.1, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.43, + 'temperature': 30.4, + 'uv_index': 4, + 'wind_bearing': 140, + 'wind_gust_speed': 26.53, + 'wind_speed': 13.78, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T06:00:00Z', + 'dew_point': 22.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.21, + 'temperature': 30.1, + 'uv_index': 3, + 'wind_bearing': 138, + 'wind_gust_speed': 24.56, + 'wind_speed': 13.74, + }), + dict({ + 'apparent_temperature': 32.0, + 'cloud_coverage': 53.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T07:00:00Z', + 'dew_point': 22.1, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.26, + 'temperature': 29.1, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 22.78, + 'wind_speed': 13.21, + }), + dict({ + 'apparent_temperature': 30.9, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.51, + 'temperature': 28.1, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 19.92, + 'wind_speed': 12.0, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 50.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T09:00:00Z', + 'dew_point': 21.7, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.8, + 'temperature': 27.2, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 17.65, + 'wind_speed': 10.97, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T10:00:00Z', + 'dew_point': 21.4, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.23, + 'temperature': 26.2, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 15.87, + 'wind_speed': 10.23, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T11:00:00Z', + 'dew_point': 21.3, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1011.79, + 'temperature': 25.4, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 13.9, + 'wind_speed': 9.39, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T12:00:00Z', + 'dew_point': 21.2, + 'humidity': 81, + 'precipitation': 0.0, + 'precipitation_probability': 47.0, + 'pressure': 1012.12, + 'temperature': 24.7, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 13.32, + 'wind_speed': 8.9, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T13:00:00Z', + 'dew_point': 21.2, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1012.18, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 13.18, + 'wind_speed': 8.59, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T14:00:00Z', + 'dew_point': 21.3, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.09, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 13.84, + 'wind_speed': 8.87, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T15:00:00Z', + 'dew_point': 21.3, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.99, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 15.08, + 'wind_speed': 8.93, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T16:00:00Z', + 'dew_point': 21.0, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 23.2, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 16.74, + 'wind_speed': 9.49, + }), + dict({ + 'apparent_temperature': 24.7, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T17:00:00Z', + 'dew_point': 20.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.75, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 17.45, + 'wind_speed': 9.12, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T18:00:00Z', + 'dew_point': 20.7, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.77, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 17.04, + 'wind_speed': 8.68, + }), + dict({ + 'apparent_temperature': 24.1, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T19:00:00Z', + 'dew_point': 20.6, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 16.8, + 'wind_speed': 8.61, + }), + dict({ + 'apparent_temperature': 23.9, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T20:00:00Z', + 'dew_point': 20.5, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.23, + 'temperature': 22.1, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 15.35, + 'wind_speed': 8.36, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 75.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T21:00:00Z', + 'dew_point': 20.6, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.49, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 14.09, + 'wind_speed': 7.77, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T22:00:00Z', + 'dew_point': 21.0, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.72, + 'temperature': 23.8, + 'uv_index': 1, + 'wind_bearing': 152, + 'wind_gust_speed': 14.04, + 'wind_speed': 7.25, + }), + dict({ + 'apparent_temperature': 27.8, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T23:00:00Z', + 'dew_point': 21.4, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.85, + 'temperature': 25.5, + 'uv_index': 2, + 'wind_bearing': 149, + 'wind_gust_speed': 15.31, + 'wind_speed': 7.14, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-13T00:00:00Z', + 'dew_point': 21.8, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.89, + 'temperature': 27.1, + 'uv_index': 4, + 'wind_bearing': 141, + 'wind_gust_speed': 16.42, + 'wind_speed': 6.89, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T01:00:00Z', + 'dew_point': 22.0, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.65, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 137, + 'wind_gust_speed': 18.64, + 'wind_speed': 6.65, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T02:00:00Z', + 'dew_point': 21.9, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.26, + 'temperature': 29.4, + 'uv_index': 5, + 'wind_bearing': 128, + 'wind_gust_speed': 21.69, + 'wind_speed': 7.12, + }), + dict({ + 'apparent_temperature': 33.0, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T03:00:00Z', + 'dew_point': 21.9, + 'humidity': 62, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.88, + 'temperature': 30.1, + 'uv_index': 6, + 'wind_bearing': 111, + 'wind_gust_speed': 23.41, + 'wind_speed': 7.33, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 72.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T04:00:00Z', + 'dew_point': 22.0, + 'humidity': 61, + 'precipitation': 0.9, + 'precipitation_probability': 12.0, + 'pressure': 1011.55, + 'temperature': 30.4, + 'uv_index': 5, + 'wind_bearing': 56, + 'wind_gust_speed': 23.1, + 'wind_speed': 8.09, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 72.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T05:00:00Z', + 'dew_point': 21.9, + 'humidity': 61, + 'precipitation': 1.9, + 'precipitation_probability': 12.0, + 'pressure': 1011.29, + 'temperature': 30.2, + 'uv_index': 4, + 'wind_bearing': 20, + 'wind_gust_speed': 21.81, + 'wind_speed': 9.46, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 74.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T06:00:00Z', + 'dew_point': 21.9, + 'humidity': 63, + 'precipitation': 2.3, + 'precipitation_probability': 11.0, + 'pressure': 1011.17, + 'temperature': 29.7, + 'uv_index': 3, + 'wind_bearing': 20, + 'wind_gust_speed': 19.72, + 'wind_speed': 9.8, + }), + dict({ + 'apparent_temperature': 31.8, + 'cloud_coverage': 69.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T07:00:00Z', + 'dew_point': 22.4, + 'humidity': 68, + 'precipitation': 1.8, + 'precipitation_probability': 10.0, + 'pressure': 1011.32, + 'temperature': 28.8, + 'uv_index': 1, + 'wind_bearing': 18, + 'wind_gust_speed': 17.55, + 'wind_speed': 9.23, + }), + dict({ + 'apparent_temperature': 30.8, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T08:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.8, + 'precipitation_probability': 10.0, + 'pressure': 1011.6, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 27, + 'wind_gust_speed': 15.08, + 'wind_speed': 8.05, + }), + dict({ + 'apparent_temperature': 29.4, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T09:00:00Z', + 'dew_point': 23.0, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.94, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 32, + 'wind_gust_speed': 12.17, + 'wind_speed': 6.68, + }), + dict({ + 'apparent_temperature': 28.5, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T10:00:00Z', + 'dew_point': 22.9, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.3, + 'temperature': 25.5, + 'uv_index': 0, + 'wind_bearing': 69, + 'wind_gust_speed': 11.64, + 'wind_speed': 6.69, + }), + dict({ + 'apparent_temperature': 27.7, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T11:00:00Z', + 'dew_point': 22.6, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.71, + 'temperature': 25.0, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 11.91, + 'wind_speed': 6.23, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T12:00:00Z', + 'dew_point': 22.3, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.96, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 12.47, + 'wind_speed': 5.73, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T13:00:00Z', + 'dew_point': 22.3, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.03, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 13.57, + 'wind_speed': 5.66, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T14:00:00Z', + 'dew_point': 22.2, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.99, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 15.07, + 'wind_speed': 5.83, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T15:00:00Z', + 'dew_point': 22.2, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.95, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 16.06, + 'wind_speed': 5.93, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T16:00:00Z', + 'dew_point': 22.0, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.9, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 153, + 'wind_gust_speed': 16.05, + 'wind_speed': 5.75, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T17:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.85, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 15.52, + 'wind_speed': 5.49, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 92.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T18:00:00Z', + 'dew_point': 21.8, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.87, + 'temperature': 23.0, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 15.01, + 'wind_speed': 5.32, + }), + dict({ + 'apparent_temperature': 25.0, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T19:00:00Z', + 'dew_point': 21.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.01, + 'temperature': 22.8, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 14.39, + 'wind_speed': 5.33, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T20:00:00Z', + 'dew_point': 21.6, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.22, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 13.79, + 'wind_speed': 5.43, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T21:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.41, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 14.12, + 'wind_speed': 5.52, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 77.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T22:00:00Z', + 'dew_point': 22.1, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.59, + 'temperature': 24.3, + 'uv_index': 1, + 'wind_bearing': 147, + 'wind_gust_speed': 16.14, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T23:00:00Z', + 'dew_point': 22.4, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.74, + 'temperature': 25.7, + 'uv_index': 2, + 'wind_bearing': 146, + 'wind_gust_speed': 19.09, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 30.5, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T00:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.78, + 'temperature': 27.4, + 'uv_index': 4, + 'wind_bearing': 143, + 'wind_gust_speed': 21.6, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 32.2, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T01:00:00Z', + 'dew_point': 23.2, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.61, + 'temperature': 28.7, + 'uv_index': 5, + 'wind_bearing': 138, + 'wind_gust_speed': 23.36, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T02:00:00Z', + 'dew_point': 23.2, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.32, + 'temperature': 29.9, + 'uv_index': 6, + 'wind_bearing': 111, + 'wind_gust_speed': 24.72, + 'wind_speed': 4.99, + }), + dict({ + 'apparent_temperature': 34.4, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T03:00:00Z', + 'dew_point': 23.3, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.04, + 'temperature': 30.7, + 'uv_index': 6, + 'wind_bearing': 354, + 'wind_gust_speed': 25.23, + 'wind_speed': 4.74, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T04:00:00Z', + 'dew_point': 23.4, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.77, + 'temperature': 31.0, + 'uv_index': 6, + 'wind_bearing': 341, + 'wind_gust_speed': 24.6, + 'wind_speed': 4.79, + }), + dict({ + 'apparent_temperature': 34.5, + 'cloud_coverage': 60.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T05:00:00Z', + 'dew_point': 23.2, + 'humidity': 64, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1012.53, + 'temperature': 30.7, + 'uv_index': 5, + 'wind_bearing': 336, + 'wind_gust_speed': 23.28, + 'wind_speed': 5.07, + }), + dict({ + 'apparent_temperature': 33.8, + 'cloud_coverage': 59.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T06:00:00Z', + 'dew_point': 23.1, + 'humidity': 66, + 'precipitation': 0.2, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1012.49, + 'temperature': 30.2, + 'uv_index': 3, + 'wind_bearing': 336, + 'wind_gust_speed': 22.05, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 32.9, + 'cloud_coverage': 53.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T07:00:00Z', + 'dew_point': 23.0, + 'humidity': 68, + 'precipitation': 0.2, + 'precipitation_probability': 40.0, + 'pressure': 1012.73, + 'temperature': 29.5, + 'uv_index': 2, + 'wind_bearing': 339, + 'wind_gust_speed': 21.18, + 'wind_speed': 5.63, + }), + dict({ + 'apparent_temperature': 31.6, + 'cloud_coverage': 43.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T08:00:00Z', + 'dew_point': 22.8, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 45.0, + 'pressure': 1013.16, + 'temperature': 28.4, + 'uv_index': 0, + 'wind_bearing': 342, + 'wind_gust_speed': 20.35, + 'wind_speed': 5.93, + }), + dict({ + 'apparent_temperature': 30.0, + 'cloud_coverage': 35.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T09:00:00Z', + 'dew_point': 22.5, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1013.62, + 'temperature': 27.1, + 'uv_index': 0, + 'wind_bearing': 347, + 'wind_gust_speed': 19.42, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 29.0, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T10:00:00Z', + 'dew_point': 22.4, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.09, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 348, + 'wind_gust_speed': 18.19, + 'wind_speed': 5.31, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T11:00:00Z', + 'dew_point': 22.4, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.56, + 'temperature': 25.5, + 'uv_index': 0, + 'wind_bearing': 177, + 'wind_gust_speed': 16.79, + 'wind_speed': 4.28, + }), + dict({ + 'apparent_temperature': 27.5, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T12:00:00Z', + 'dew_point': 22.3, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.87, + 'temperature': 24.9, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 15.61, + 'wind_speed': 3.72, + }), + dict({ + 'apparent_temperature': 26.6, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T13:00:00Z', + 'dew_point': 22.1, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.91, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 14.7, + 'wind_speed': 4.11, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T14:00:00Z', + 'dew_point': 21.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.8, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 13.81, + 'wind_speed': 4.97, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T15:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.66, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 170, + 'wind_gust_speed': 12.88, + 'wind_speed': 5.57, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 37.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T16:00:00Z', + 'dew_point': 21.5, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.54, + 'temperature': 22.7, + 'uv_index': 0, + 'wind_bearing': 168, + 'wind_gust_speed': 12.0, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 39.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T17:00:00Z', + 'dew_point': 21.3, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.45, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 165, + 'wind_gust_speed': 11.43, + 'wind_speed': 5.48, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T18:00:00Z', + 'dew_point': 21.4, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 44.0, + 'pressure': 1014.45, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 11.42, + 'wind_speed': 5.38, + }), + dict({ + 'apparent_temperature': 25.0, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T19:00:00Z', + 'dew_point': 21.6, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 52.0, + 'pressure': 1014.63, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 12.15, + 'wind_speed': 5.39, + }), + dict({ + 'apparent_temperature': 25.6, + 'cloud_coverage': 38.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T20:00:00Z', + 'dew_point': 21.8, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 51.0, + 'pressure': 1014.91, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 13.54, + 'wind_speed': 5.45, + }), + dict({ + 'apparent_temperature': 26.6, + 'cloud_coverage': 36.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T21:00:00Z', + 'dew_point': 22.0, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 42.0, + 'pressure': 1015.18, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 15.48, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 28.5, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T22:00:00Z', + 'dew_point': 22.5, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 28.999999999999996, + 'pressure': 1015.4, + 'temperature': 25.7, + 'uv_index': 1, + 'wind_bearing': 158, + 'wind_gust_speed': 17.86, + 'wind_speed': 5.84, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 77, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.54, + 'temperature': 27.2, + 'uv_index': 2, + 'wind_bearing': 155, + 'wind_gust_speed': 20.19, + 'wind_speed': 6.09, + }), + dict({ + 'apparent_temperature': 32.1, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-15T00:00:00Z', + 'dew_point': 23.3, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.55, + 'temperature': 28.6, + 'uv_index': 4, + 'wind_bearing': 152, + 'wind_gust_speed': 21.83, + 'wind_speed': 6.42, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-15T01:00:00Z', + 'dew_point': 23.5, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.35, + 'temperature': 29.6, + 'uv_index': 6, + 'wind_bearing': 144, + 'wind_gust_speed': 22.56, + 'wind_speed': 6.91, + }), + dict({ + 'apparent_temperature': 34.2, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T02:00:00Z', + 'dew_point': 23.5, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.0, + 'temperature': 30.4, + 'uv_index': 7, + 'wind_bearing': 336, + 'wind_gust_speed': 22.83, + 'wind_speed': 7.47, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T03:00:00Z', + 'dew_point': 23.5, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.62, + 'temperature': 30.9, + 'uv_index': 7, + 'wind_bearing': 336, + 'wind_gust_speed': 22.98, + 'wind_speed': 7.95, + }), + dict({ + 'apparent_temperature': 35.4, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T04:00:00Z', + 'dew_point': 23.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.25, + 'temperature': 31.3, + 'uv_index': 6, + 'wind_bearing': 341, + 'wind_gust_speed': 23.21, + 'wind_speed': 8.44, + }), + dict({ + 'apparent_temperature': 35.6, + 'cloud_coverage': 44.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T05:00:00Z', + 'dew_point': 23.7, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.95, + 'temperature': 31.5, + 'uv_index': 5, + 'wind_bearing': 344, + 'wind_gust_speed': 23.46, + 'wind_speed': 8.95, + }), + dict({ + 'apparent_temperature': 35.1, + 'cloud_coverage': 42.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T06:00:00Z', + 'dew_point': 23.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.83, + 'temperature': 31.1, + 'uv_index': 3, + 'wind_bearing': 347, + 'wind_gust_speed': 23.64, + 'wind_speed': 9.13, + }), + dict({ + 'apparent_temperature': 34.1, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T07:00:00Z', + 'dew_point': 23.4, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.96, + 'temperature': 30.3, + 'uv_index': 2, + 'wind_bearing': 350, + 'wind_gust_speed': 23.66, + 'wind_speed': 8.78, + }), + dict({ + 'apparent_temperature': 32.4, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T08:00:00Z', + 'dew_point': 23.1, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.25, + 'temperature': 29.0, + 'uv_index': 0, + 'wind_bearing': 356, + 'wind_gust_speed': 23.51, + 'wind_speed': 8.13, + }), + dict({ + 'apparent_temperature': 31.1, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T09:00:00Z', + 'dew_point': 22.9, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.61, + 'temperature': 27.9, + 'uv_index': 0, + 'wind_bearing': 3, + 'wind_gust_speed': 23.21, + 'wind_speed': 7.48, + }), + dict({ + 'apparent_temperature': 30.0, + 'cloud_coverage': 43.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T10:00:00Z', + 'dew_point': 22.8, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.02, + 'temperature': 26.9, + 'uv_index': 0, + 'wind_bearing': 20, + 'wind_gust_speed': 22.68, + 'wind_speed': 6.83, + }), + dict({ + 'apparent_temperature': 29.2, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T11:00:00Z', + 'dew_point': 22.8, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.43, + 'temperature': 26.2, + 'uv_index': 0, + 'wind_bearing': 129, + 'wind_gust_speed': 22.04, + 'wind_speed': 6.1, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T12:00:00Z', + 'dew_point': 22.7, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.71, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 21.64, + 'wind_speed': 5.6, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T13:00:00Z', + 'dew_point': 23.2, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.52, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 164, + 'wind_gust_speed': 16.35, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T14:00:00Z', + 'dew_point': 22.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.37, + 'temperature': 24.6, + 'uv_index': 0, + 'wind_bearing': 168, + 'wind_gust_speed': 17.11, + 'wind_speed': 5.79, + }), + dict({ + 'apparent_temperature': 26.9, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T15:00:00Z', + 'dew_point': 22.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.21, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 17.32, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T16:00:00Z', + 'dew_point': 22.6, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.07, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 201, + 'wind_gust_speed': 16.6, + 'wind_speed': 5.27, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T17:00:00Z', + 'dew_point': 22.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.95, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 219, + 'wind_gust_speed': 15.52, + 'wind_speed': 4.62, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T18:00:00Z', + 'dew_point': 22.3, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.88, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 216, + 'wind_gust_speed': 14.64, + 'wind_speed': 4.32, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T19:00:00Z', + 'dew_point': 22.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.91, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 198, + 'wind_gust_speed': 14.06, + 'wind_speed': 4.73, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T20:00:00Z', + 'dew_point': 22.4, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.99, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 189, + 'wind_gust_speed': 13.7, + 'wind_speed': 5.49, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T21:00:00Z', + 'dew_point': 22.5, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.07, + 'temperature': 24.4, + 'uv_index': 0, + 'wind_bearing': 183, + 'wind_gust_speed': 13.77, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 28.3, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T22:00:00Z', + 'dew_point': 22.6, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.12, + 'temperature': 25.5, + 'uv_index': 1, + 'wind_bearing': 179, + 'wind_gust_speed': 14.38, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 29.9, + 'cloud_coverage': 52.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.13, + 'temperature': 26.9, + 'uv_index': 2, + 'wind_bearing': 170, + 'wind_gust_speed': 15.2, + 'wind_speed': 5.27, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 44.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T00:00:00Z', + 'dew_point': 22.9, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.04, + 'temperature': 28.0, + 'uv_index': 4, + 'wind_bearing': 155, + 'wind_gust_speed': 15.85, + 'wind_speed': 4.76, + }), + dict({ + 'apparent_temperature': 32.5, + 'cloud_coverage': 24.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T01:00:00Z', + 'dew_point': 22.6, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.52, + 'temperature': 29.2, + 'uv_index': 6, + 'wind_bearing': 110, + 'wind_gust_speed': 16.27, + 'wind_speed': 6.81, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 16.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T02:00:00Z', + 'dew_point': 22.4, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.01, + 'temperature': 30.2, + 'uv_index': 8, + 'wind_bearing': 30, + 'wind_gust_speed': 16.55, + 'wind_speed': 6.86, + }), + dict({ + 'apparent_temperature': 34.2, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T03:00:00Z', + 'dew_point': 22.0, + 'humidity': 59, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.45, + 'temperature': 31.1, + 'uv_index': 8, + 'wind_bearing': 17, + 'wind_gust_speed': 16.52, + 'wind_speed': 6.8, + }), + dict({ + 'apparent_temperature': 34.7, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T04:00:00Z', + 'dew_point': 21.9, + 'humidity': 57, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.89, + 'temperature': 31.5, + 'uv_index': 8, + 'wind_bearing': 17, + 'wind_gust_speed': 16.08, + 'wind_speed': 6.62, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T05:00:00Z', + 'dew_point': 21.9, + 'humidity': 56, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.39, + 'temperature': 31.8, + 'uv_index': 6, + 'wind_bearing': 20, + 'wind_gust_speed': 15.48, + 'wind_speed': 6.45, + }), + dict({ + 'apparent_temperature': 34.5, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T06:00:00Z', + 'dew_point': 21.7, + 'humidity': 56, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.11, + 'temperature': 31.4, + 'uv_index': 4, + 'wind_bearing': 26, + 'wind_gust_speed': 15.08, + 'wind_speed': 6.43, + }), + dict({ + 'apparent_temperature': 33.6, + 'cloud_coverage': 7.000000000000001, + 'condition': 'sunny', + 'datetime': '2023-09-16T07:00:00Z', + 'dew_point': 21.7, + 'humidity': 59, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.15, + 'temperature': 30.7, + 'uv_index': 2, + 'wind_bearing': 39, + 'wind_gust_speed': 14.88, + 'wind_speed': 6.61, + }), + dict({ + 'apparent_temperature': 32.5, + 'cloud_coverage': 2.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.41, + 'temperature': 29.6, + 'uv_index': 0, + 'wind_bearing': 72, + 'wind_gust_speed': 14.82, + 'wind_speed': 6.95, + }), + dict({ + 'apparent_temperature': 31.4, + 'cloud_coverage': 2.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T09:00:00Z', + 'dew_point': 22.1, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.75, + 'temperature': 28.5, + 'uv_index': 0, + 'wind_bearing': 116, + 'wind_gust_speed': 15.13, + 'wind_speed': 7.45, + }), + dict({ + 'apparent_temperature': 30.5, + 'cloud_coverage': 13.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T10:00:00Z', + 'dew_point': 22.3, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.13, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 16.09, + 'wind_speed': 8.15, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T11:00:00Z', + 'dew_point': 22.6, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.47, + 'temperature': 26.9, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 17.37, + 'wind_speed': 8.87, + }), + dict({ + 'apparent_temperature': 29.3, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T12:00:00Z', + 'dew_point': 22.9, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.6, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 18.29, + 'wind_speed': 9.21, + }), + dict({ + 'apparent_temperature': 28.7, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T13:00:00Z', + 'dew_point': 23.0, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.41, + 'temperature': 25.7, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 18.49, + 'wind_speed': 8.96, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 55.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T14:00:00Z', + 'dew_point': 22.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.01, + 'temperature': 25.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 18.47, + 'wind_speed': 8.45, + }), + dict({ + 'apparent_temperature': 27.2, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T15:00:00Z', + 'dew_point': 22.7, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.55, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 18.79, + 'wind_speed': 8.1, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T16:00:00Z', + 'dew_point': 22.6, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.1, + 'temperature': 24.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 19.81, + 'wind_speed': 8.15, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T17:00:00Z', + 'dew_point': 22.6, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.68, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 20.96, + 'wind_speed': 8.3, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T18:00:00Z', + 'dew_point': 22.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 21.41, + 'wind_speed': 8.24, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T19:00:00Z', + 'dew_point': 22.5, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 20.42, + 'wind_speed': 7.62, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T20:00:00Z', + 'dew_point': 22.6, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.31, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 18.61, + 'wind_speed': 6.66, + }), + dict({ + 'apparent_temperature': 27.7, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T21:00:00Z', + 'dew_point': 22.6, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.37, + 'temperature': 24.9, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 17.14, + 'wind_speed': 5.86, + }), + dict({ + 'apparent_temperature': 28.9, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T22:00:00Z', + 'dew_point': 22.6, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.46, + 'temperature': 26.0, + 'uv_index': 1, + 'wind_bearing': 161, + 'wind_gust_speed': 16.78, + 'wind_speed': 5.5, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 39.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.51, + 'temperature': 27.5, + 'uv_index': 2, + 'wind_bearing': 165, + 'wind_gust_speed': 17.21, + 'wind_speed': 5.56, + }), + dict({ + 'apparent_temperature': 31.7, + 'cloud_coverage': 33.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T00:00:00Z', + 'dew_point': 22.8, + 'humidity': 71, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 28.5, + 'uv_index': 4, + 'wind_bearing': 174, + 'wind_gust_speed': 17.96, + 'wind_speed': 6.04, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T01:00:00Z', + 'dew_point': 22.7, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.98, + 'temperature': 29.4, + 'uv_index': 6, + 'wind_bearing': 192, + 'wind_gust_speed': 19.15, + 'wind_speed': 7.23, + }), + dict({ + 'apparent_temperature': 33.6, + 'cloud_coverage': 28.999999999999996, + 'condition': 'sunny', + 'datetime': '2023-09-17T02:00:00Z', + 'dew_point': 22.8, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.38, + 'temperature': 30.1, + 'uv_index': 7, + 'wind_bearing': 225, + 'wind_gust_speed': 20.89, + 'wind_speed': 8.9, + }), + dict({ + 'apparent_temperature': 34.1, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T03:00:00Z', + 'dew_point': 22.8, + 'humidity': 63, + 'precipitation': 0.3, + 'precipitation_probability': 9.0, + 'pressure': 1009.75, + 'temperature': 30.7, + 'uv_index': 8, + 'wind_bearing': 264, + 'wind_gust_speed': 22.67, + 'wind_speed': 10.27, + }), + dict({ + 'apparent_temperature': 33.9, + 'cloud_coverage': 37.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T04:00:00Z', + 'dew_point': 22.5, + 'humidity': 62, + 'precipitation': 0.4, + 'precipitation_probability': 10.0, + 'pressure': 1009.18, + 'temperature': 30.5, + 'uv_index': 7, + 'wind_bearing': 293, + 'wind_gust_speed': 23.93, + 'wind_speed': 10.82, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T05:00:00Z', + 'dew_point': 22.4, + 'humidity': 63, + 'precipitation': 0.6, + 'precipitation_probability': 12.0, + 'pressure': 1008.71, + 'temperature': 30.1, + 'uv_index': 5, + 'wind_bearing': 308, + 'wind_gust_speed': 24.39, + 'wind_speed': 10.72, + }), + dict({ + 'apparent_temperature': 32.7, + 'cloud_coverage': 50.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T06:00:00Z', + 'dew_point': 22.2, + 'humidity': 64, + 'precipitation': 0.7, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1008.46, + 'temperature': 29.6, + 'uv_index': 3, + 'wind_bearing': 312, + 'wind_gust_speed': 23.9, + 'wind_speed': 10.28, + }), + dict({ + 'apparent_temperature': 31.8, + 'cloud_coverage': 47.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T07:00:00Z', + 'dew_point': 22.1, + 'humidity': 67, + 'precipitation': 0.7, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1008.53, + 'temperature': 28.9, + 'uv_index': 1, + 'wind_bearing': 312, + 'wind_gust_speed': 22.3, + 'wind_speed': 9.59, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 70, + 'precipitation': 0.6, + 'precipitation_probability': 15.0, + 'pressure': 1008.82, + 'temperature': 27.9, + 'uv_index': 0, + 'wind_bearing': 305, + 'wind_gust_speed': 19.73, + 'wind_speed': 8.58, + }), + dict({ + 'apparent_temperature': 29.6, + 'cloud_coverage': 35.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T09:00:00Z', + 'dew_point': 22.0, + 'humidity': 74, + 'precipitation': 0.5, + 'precipitation_probability': 15.0, + 'pressure': 1009.21, + 'temperature': 27.0, + 'uv_index': 0, + 'wind_bearing': 291, + 'wind_gust_speed': 16.49, + 'wind_speed': 7.34, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 33.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T10:00:00Z', + 'dew_point': 21.9, + 'humidity': 78, + 'precipitation': 0.4, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1009.65, + 'temperature': 26.1, + 'uv_index': 0, + 'wind_bearing': 257, + 'wind_gust_speed': 12.71, + 'wind_speed': 5.91, + }), + dict({ + 'apparent_temperature': 27.8, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T11:00:00Z', + 'dew_point': 21.9, + 'humidity': 82, + 'precipitation': 0.3, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1010.04, + 'temperature': 25.3, + 'uv_index': 0, + 'wind_bearing': 212, + 'wind_gust_speed': 9.16, + 'wind_speed': 4.54, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 36.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T12:00:00Z', + 'dew_point': 21.9, + 'humidity': 85, + 'precipitation': 0.3, + 'precipitation_probability': 28.000000000000004, + 'pressure': 1010.24, + 'temperature': 24.6, + 'uv_index': 0, + 'wind_bearing': 192, + 'wind_gust_speed': 7.09, + 'wind_speed': 3.62, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T13:00:00Z', + 'dew_point': 22.0, + 'humidity': 88, + 'precipitation': 0.3, + 'precipitation_probability': 30.0, + 'pressure': 1010.15, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 185, + 'wind_gust_speed': 7.2, + 'wind_speed': 3.27, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 44.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T14:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.3, + 'precipitation_probability': 30.0, + 'pressure': 1009.87, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 8.37, + 'wind_speed': 3.22, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 49.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T15:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.2, + 'precipitation_probability': 31.0, + 'pressure': 1009.56, + 'temperature': 23.2, + 'uv_index': 0, + 'wind_bearing': 180, + 'wind_gust_speed': 9.21, + 'wind_speed': 3.3, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 53.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T16:00:00Z', + 'dew_point': 21.8, + 'humidity': 94, + 'precipitation': 0.2, + 'precipitation_probability': 33.0, + 'pressure': 1009.29, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 9.0, + 'wind_speed': 3.46, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T17:00:00Z', + 'dew_point': 21.7, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 35.0, + 'pressure': 1009.09, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 186, + 'wind_gust_speed': 8.37, + 'wind_speed': 3.72, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T18:00:00Z', + 'dew_point': 21.6, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 37.0, + 'pressure': 1009.01, + 'temperature': 22.5, + 'uv_index': 0, + 'wind_bearing': 201, + 'wind_gust_speed': 7.99, + 'wind_speed': 4.07, + }), + dict({ + 'apparent_temperature': 24.9, + 'cloud_coverage': 62.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T19:00:00Z', + 'dew_point': 21.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 39.0, + 'pressure': 1009.07, + 'temperature': 22.7, + 'uv_index': 0, + 'wind_bearing': 258, + 'wind_gust_speed': 8.18, + 'wind_speed': 4.55, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T20:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 39.0, + 'pressure': 1009.23, + 'temperature': 23.0, + 'uv_index': 0, + 'wind_bearing': 305, + 'wind_gust_speed': 8.77, + 'wind_speed': 5.17, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T21:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 38.0, + 'pressure': 1009.47, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 318, + 'wind_gust_speed': 9.69, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T22:00:00Z', + 'dew_point': 21.8, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 30.0, + 'pressure': 1009.77, + 'temperature': 24.2, + 'uv_index': 1, + 'wind_bearing': 324, + 'wind_gust_speed': 10.88, + 'wind_speed': 6.26, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 80.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T23:00:00Z', + 'dew_point': 21.9, + 'humidity': 83, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1010.09, + 'temperature': 25.1, + 'uv_index': 2, + 'wind_bearing': 329, + 'wind_gust_speed': 12.21, + 'wind_speed': 6.68, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 87.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T00:00:00Z', + 'dew_point': 21.9, + 'humidity': 80, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1010.33, + 'temperature': 25.7, + 'uv_index': 3, + 'wind_bearing': 332, + 'wind_gust_speed': 13.52, + 'wind_speed': 7.12, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 67.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T01:00:00Z', + 'dew_point': 21.7, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1007.43, + 'temperature': 27.2, + 'uv_index': 5, + 'wind_bearing': 330, + 'wind_gust_speed': 11.36, + 'wind_speed': 11.36, + }), + dict({ + 'apparent_temperature': 30.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T02:00:00Z', + 'dew_point': 21.6, + 'humidity': 70, + 'precipitation': 0.3, + 'precipitation_probability': 9.0, + 'pressure': 1007.05, + 'temperature': 27.5, + 'uv_index': 6, + 'wind_bearing': 332, + 'wind_gust_speed': 12.06, + 'wind_speed': 12.06, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T03:00:00Z', + 'dew_point': 21.6, + 'humidity': 69, + 'precipitation': 0.5, + 'precipitation_probability': 10.0, + 'pressure': 1006.67, + 'temperature': 27.8, + 'uv_index': 6, + 'wind_bearing': 333, + 'wind_gust_speed': 12.81, + 'wind_speed': 12.81, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 67.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T04:00:00Z', + 'dew_point': 21.5, + 'humidity': 68, + 'precipitation': 0.4, + 'precipitation_probability': 10.0, + 'pressure': 1006.28, + 'temperature': 28.0, + 'uv_index': 5, + 'wind_bearing': 335, + 'wind_gust_speed': 13.68, + 'wind_speed': 13.68, + }), + dict({ + 'apparent_temperature': 30.7, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T05:00:00Z', + 'dew_point': 21.4, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1005.89, + 'temperature': 28.1, + 'uv_index': 4, + 'wind_bearing': 336, + 'wind_gust_speed': 14.61, + 'wind_speed': 14.61, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T06:00:00Z', + 'dew_point': 21.2, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 27.0, + 'pressure': 1005.67, + 'temperature': 27.9, + 'uv_index': 3, + 'wind_bearing': 338, + 'wind_gust_speed': 15.25, + 'wind_speed': 15.25, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T07:00:00Z', + 'dew_point': 21.3, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 28.000000000000004, + 'pressure': 1005.74, + 'temperature': 27.4, + 'uv_index': 1, + 'wind_bearing': 339, + 'wind_gust_speed': 15.45, + 'wind_speed': 15.45, + }), + dict({ + 'apparent_temperature': 29.1, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T08:00:00Z', + 'dew_point': 21.4, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1005.98, + 'temperature': 26.7, + 'uv_index': 0, + 'wind_bearing': 341, + 'wind_gust_speed': 15.38, + 'wind_speed': 15.38, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T09:00:00Z', + 'dew_point': 21.6, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1006.22, + 'temperature': 26.1, + 'uv_index': 0, + 'wind_bearing': 341, + 'wind_gust_speed': 15.27, + 'wind_speed': 15.27, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T10:00:00Z', + 'dew_point': 21.6, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1006.44, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 339, + 'wind_gust_speed': 15.09, + 'wind_speed': 15.09, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T11:00:00Z', + 'dew_point': 21.7, + 'humidity': 81, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1006.66, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 336, + 'wind_gust_speed': 14.88, + 'wind_speed': 14.88, + }), + dict({ + 'apparent_temperature': 27.2, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1006.79, + 'temperature': 24.8, + 'uv_index': 0, + 'wind_bearing': 333, + 'wind_gust_speed': 14.91, + 'wind_speed': 14.91, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 38.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T13:00:00Z', + 'dew_point': 21.2, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.36, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 83, + 'wind_gust_speed': 4.58, + 'wind_speed': 3.16, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T14:00:00Z', + 'dew_point': 21.2, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.96, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 4.74, + 'wind_speed': 4.52, + }), + dict({ + 'apparent_temperature': 24.5, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T15:00:00Z', + 'dew_point': 20.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.6, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 152, + 'wind_gust_speed': 5.63, + 'wind_speed': 5.63, + }), + dict({ + 'apparent_temperature': 24.0, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T16:00:00Z', + 'dew_point': 20.7, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.37, + 'temperature': 22.3, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 6.02, + 'wind_speed': 6.02, + }), + dict({ + 'apparent_temperature': 23.7, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T17:00:00Z', + 'dew_point': 20.4, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.2, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 6.15, + 'wind_speed': 6.15, + }), + dict({ + 'apparent_temperature': 23.4, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T18:00:00Z', + 'dew_point': 20.2, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.08, + 'temperature': 21.9, + 'uv_index': 0, + 'wind_bearing': 167, + 'wind_gust_speed': 6.48, + 'wind_speed': 6.48, + }), + dict({ + 'apparent_temperature': 23.2, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T19:00:00Z', + 'dew_point': 19.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.04, + 'temperature': 21.8, + 'uv_index': 0, + 'wind_bearing': 165, + 'wind_gust_speed': 7.51, + 'wind_speed': 7.51, + }), + dict({ + 'apparent_temperature': 23.4, + 'cloud_coverage': 99.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T20:00:00Z', + 'dew_point': 19.6, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.05, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 8.73, + 'wind_speed': 8.73, + }), + dict({ + 'apparent_temperature': 23.9, + 'cloud_coverage': 98.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T21:00:00Z', + 'dew_point': 19.5, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.06, + 'temperature': 22.5, + 'uv_index': 0, + 'wind_bearing': 164, + 'wind_gust_speed': 9.21, + 'wind_speed': 9.11, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 96.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T22:00:00Z', + 'dew_point': 19.7, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.09, + 'temperature': 23.8, + 'uv_index': 1, + 'wind_bearing': 171, + 'wind_gust_speed': 9.03, + 'wind_speed': 7.91, + }), + ]), + }), + }) +# --- +# name: test_hourly_forecast[get_forecast] + dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 79.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T14:00:00Z', + 'dew_point': 21.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.24, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 264, + 'wind_gust_speed': 13.44, + 'wind_speed': 6.62, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 80.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T15:00:00Z', + 'dew_point': 21.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.24, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 261, + 'wind_gust_speed': 11.91, + 'wind_speed': 6.64, + }), + dict({ + 'apparent_temperature': 23.8, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T16:00:00Z', + 'dew_point': 21.1, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.12, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 252, + 'wind_gust_speed': 11.15, + 'wind_speed': 6.14, + }), + dict({ + 'apparent_temperature': 23.5, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T17:00:00Z', + 'dew_point': 20.9, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.03, + 'temperature': 21.7, + 'uv_index': 0, + 'wind_bearing': 248, + 'wind_gust_speed': 11.57, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 23.3, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T18:00:00Z', + 'dew_point': 20.8, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.05, + 'temperature': 21.6, + 'uv_index': 0, + 'wind_bearing': 237, + 'wind_gust_speed': 12.42, + 'wind_speed': 5.86, + }), + dict({ + 'apparent_temperature': 23.0, + 'cloud_coverage': 75.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T19:00:00Z', + 'dew_point': 20.6, + 'humidity': 96, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.03, + 'temperature': 21.3, + 'uv_index': 0, + 'wind_bearing': 224, + 'wind_gust_speed': 11.3, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 22.8, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T20:00:00Z', + 'dew_point': 20.4, + 'humidity': 96, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.31, + 'temperature': 21.2, + 'uv_index': 0, + 'wind_bearing': 221, + 'wind_gust_speed': 10.57, + 'wind_speed': 5.13, + }), + dict({ + 'apparent_temperature': 23.1, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-08T21:00:00Z', + 'dew_point': 20.5, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.55, + 'temperature': 21.4, + 'uv_index': 0, + 'wind_bearing': 237, + 'wind_gust_speed': 10.63, + 'wind_speed': 5.7, + }), + dict({ + 'apparent_temperature': 24.9, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-08T22:00:00Z', + 'dew_point': 21.3, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.79, + 'temperature': 22.8, + 'uv_index': 1, + 'wind_bearing': 258, + 'wind_gust_speed': 10.47, + 'wind_speed': 5.22, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T23:00:00Z', + 'dew_point': 21.3, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.95, + 'temperature': 24.0, + 'uv_index': 2, + 'wind_bearing': 282, + 'wind_gust_speed': 12.74, + 'wind_speed': 5.71, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T00:00:00Z', + 'dew_point': 21.5, + 'humidity': 80, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.35, + 'temperature': 25.1, + 'uv_index': 3, + 'wind_bearing': 294, + 'wind_gust_speed': 13.87, + 'wind_speed': 6.53, + }), + dict({ + 'apparent_temperature': 29.0, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T01:00:00Z', + 'dew_point': 21.8, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.48, + 'temperature': 26.5, + 'uv_index': 5, + 'wind_bearing': 308, + 'wind_gust_speed': 16.04, + 'wind_speed': 6.54, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T02:00:00Z', + 'dew_point': 22.0, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.23, + 'temperature': 27.6, + 'uv_index': 6, + 'wind_bearing': 314, + 'wind_gust_speed': 18.1, + 'wind_speed': 7.32, + }), + dict({ + 'apparent_temperature': 31.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T03:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.86, + 'temperature': 28.3, + 'uv_index': 6, + 'wind_bearing': 317, + 'wind_gust_speed': 20.77, + 'wind_speed': 9.1, + }), + dict({ + 'apparent_temperature': 31.5, + 'cloud_coverage': 69.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T04:00:00Z', + 'dew_point': 22.1, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.65, + 'temperature': 28.6, + 'uv_index': 6, + 'wind_bearing': 311, + 'wind_gust_speed': 21.27, + 'wind_speed': 10.21, + }), + dict({ + 'apparent_temperature': 31.3, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T05:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.48, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 317, + 'wind_gust_speed': 19.62, + 'wind_speed': 10.53, + }), + dict({ + 'apparent_temperature': 30.8, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T06:00:00Z', + 'dew_point': 22.2, + 'humidity': 71, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.54, + 'temperature': 27.9, + 'uv_index': 3, + 'wind_bearing': 335, + 'wind_gust_speed': 18.98, + 'wind_speed': 8.63, + }), + dict({ + 'apparent_temperature': 29.9, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T07:00:00Z', + 'dew_point': 22.2, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.76, + 'temperature': 27.1, + 'uv_index': 2, + 'wind_bearing': 338, + 'wind_gust_speed': 17.04, + 'wind_speed': 7.75, + }), + dict({ + 'apparent_temperature': 29.1, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T08:00:00Z', + 'dew_point': 22.1, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.05, + 'temperature': 26.4, + 'uv_index': 0, + 'wind_bearing': 342, + 'wind_gust_speed': 14.75, + 'wind_speed': 6.26, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T09:00:00Z', + 'dew_point': 22.0, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.38, + 'temperature': 25.4, + 'uv_index': 0, + 'wind_bearing': 344, + 'wind_gust_speed': 10.43, + 'wind_speed': 5.2, + }), + dict({ + 'apparent_temperature': 26.9, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T10:00:00Z', + 'dew_point': 21.9, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.73, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 339, + 'wind_gust_speed': 6.95, + 'wind_speed': 3.59, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T11:00:00Z', + 'dew_point': 21.8, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.3, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 326, + 'wind_gust_speed': 5.27, + 'wind_speed': 2.1, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 53.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.52, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 257, + 'wind_gust_speed': 5.48, + 'wind_speed': 0.93, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T13:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.53, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 188, + 'wind_gust_speed': 4.44, + 'wind_speed': 1.79, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T14:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.46, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 183, + 'wind_gust_speed': 4.49, + 'wind_speed': 2.19, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T15:00:00Z', + 'dew_point': 21.4, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.21, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 179, + 'wind_gust_speed': 5.32, + 'wind_speed': 2.65, + }), + dict({ + 'apparent_temperature': 24.0, + 'cloud_coverage': 42.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T16:00:00Z', + 'dew_point': 21.1, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.09, + 'temperature': 22.1, + 'uv_index': 0, + 'wind_bearing': 173, + 'wind_gust_speed': 5.81, + 'wind_speed': 3.2, + }), + dict({ + 'apparent_temperature': 23.7, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T17:00:00Z', + 'dew_point': 20.9, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.88, + 'temperature': 21.9, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 5.53, + 'wind_speed': 3.16, + }), + dict({ + 'apparent_temperature': 23.3, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T18:00:00Z', + 'dew_point': 20.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.94, + 'temperature': 21.6, + 'uv_index': 0, + 'wind_bearing': 153, + 'wind_gust_speed': 6.09, + 'wind_speed': 3.36, + }), + dict({ + 'apparent_temperature': 23.1, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T19:00:00Z', + 'dew_point': 20.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.96, + 'temperature': 21.4, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 6.83, + 'wind_speed': 3.71, + }), + dict({ + 'apparent_temperature': 22.5, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T20:00:00Z', + 'dew_point': 20.0, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 21.0, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 7.98, + 'wind_speed': 4.27, + }), + dict({ + 'apparent_temperature': 22.8, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T21:00:00Z', + 'dew_point': 20.2, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.61, + 'temperature': 21.2, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 8.4, + 'wind_speed': 4.69, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T22:00:00Z', + 'dew_point': 21.3, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.87, + 'temperature': 23.1, + 'uv_index': 1, + 'wind_bearing': 150, + 'wind_gust_speed': 7.66, + 'wind_speed': 4.33, + }), + dict({ + 'apparent_temperature': 28.3, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T23:00:00Z', + 'dew_point': 22.3, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 25.6, + 'uv_index': 2, + 'wind_bearing': 123, + 'wind_gust_speed': 9.63, + 'wind_speed': 3.91, + }), + dict({ + 'apparent_temperature': 30.4, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T00:00:00Z', + 'dew_point': 22.6, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 27.4, + 'uv_index': 4, + 'wind_bearing': 105, + 'wind_gust_speed': 12.59, + 'wind_speed': 3.96, + }), + dict({ + 'apparent_temperature': 32.2, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T01:00:00Z', + 'dew_point': 22.9, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.79, + 'temperature': 28.9, + 'uv_index': 5, + 'wind_bearing': 99, + 'wind_gust_speed': 14.17, + 'wind_speed': 4.06, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 62.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-10T02:00:00Z', + 'dew_point': 22.9, + 'humidity': 66, + 'precipitation': 0.3, + 'precipitation_probability': 7.000000000000001, + 'pressure': 1011.29, + 'temperature': 29.9, + 'uv_index': 6, + 'wind_bearing': 93, + 'wind_gust_speed': 17.75, + 'wind_speed': 4.87, + }), + dict({ + 'apparent_temperature': 34.3, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T03:00:00Z', + 'dew_point': 23.1, + 'humidity': 64, + 'precipitation': 0.3, + 'precipitation_probability': 11.0, + 'pressure': 1010.78, + 'temperature': 30.6, + 'uv_index': 6, + 'wind_bearing': 78, + 'wind_gust_speed': 17.43, + 'wind_speed': 4.54, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 74.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T04:00:00Z', + 'dew_point': 23.2, + 'humidity': 66, + 'precipitation': 0.4, + 'precipitation_probability': 15.0, + 'pressure': 1010.37, + 'temperature': 30.3, + 'uv_index': 5, + 'wind_bearing': 60, + 'wind_gust_speed': 15.24, + 'wind_speed': 4.9, + }), + dict({ + 'apparent_temperature': 33.7, + 'cloud_coverage': 79.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T05:00:00Z', + 'dew_point': 23.3, + 'humidity': 67, + 'precipitation': 0.7, + 'precipitation_probability': 17.0, + 'pressure': 1010.09, + 'temperature': 30.0, + 'uv_index': 4, + 'wind_bearing': 80, + 'wind_gust_speed': 13.53, + 'wind_speed': 5.98, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 80.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T06:00:00Z', + 'dew_point': 23.4, + 'humidity': 70, + 'precipitation': 1.0, + 'precipitation_probability': 17.0, + 'pressure': 1010.0, + 'temperature': 29.5, + 'uv_index': 3, + 'wind_bearing': 83, + 'wind_gust_speed': 12.55, + 'wind_speed': 6.84, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 88.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T07:00:00Z', + 'dew_point': 23.4, + 'humidity': 73, + 'precipitation': 0.4, + 'precipitation_probability': 16.0, + 'pressure': 1010.27, + 'temperature': 28.7, + 'uv_index': 2, + 'wind_bearing': 90, + 'wind_gust_speed': 10.16, + 'wind_speed': 6.07, + }), + dict({ + 'apparent_temperature': 30.9, + 'cloud_coverage': 92.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T08:00:00Z', + 'dew_point': 23.2, + 'humidity': 77, + 'precipitation': 0.5, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1010.71, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 101, + 'wind_gust_speed': 8.18, + 'wind_speed': 4.82, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 93.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T09:00:00Z', + 'dew_point': 23.2, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.9, + 'temperature': 26.5, + 'uv_index': 0, + 'wind_bearing': 128, + 'wind_gust_speed': 8.89, + 'wind_speed': 4.95, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T10:00:00Z', + 'dew_point': 23.0, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.12, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 134, + 'wind_gust_speed': 10.03, + 'wind_speed': 4.52, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 87.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T11:00:00Z', + 'dew_point': 22.8, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.43, + 'temperature': 25.1, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 12.4, + 'wind_speed': 5.41, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T12:00:00Z', + 'dew_point': 22.5, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.58, + 'temperature': 24.8, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 16.36, + 'wind_speed': 6.31, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T13:00:00Z', + 'dew_point': 22.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.55, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 19.66, + 'wind_speed': 7.23, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T14:00:00Z', + 'dew_point': 22.2, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.4, + 'temperature': 24.3, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 21.15, + 'wind_speed': 7.46, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T15:00:00Z', + 'dew_point': 22.0, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.23, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 22.26, + 'wind_speed': 7.84, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T16:00:00Z', + 'dew_point': 21.8, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.01, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 23.53, + 'wind_speed': 8.63, + }), + dict({ + 'apparent_temperature': 25.6, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-10T17:00:00Z', + 'dew_point': 21.6, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.78, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 22.83, + 'wind_speed': 8.61, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T18:00:00Z', + 'dew_point': 21.5, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.69, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 23.7, + 'wind_speed': 8.7, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T19:00:00Z', + 'dew_point': 21.4, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.77, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 24.24, + 'wind_speed': 8.74, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T20:00:00Z', + 'dew_point': 21.6, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.89, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 23.99, + 'wind_speed': 8.81, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T21:00:00Z', + 'dew_point': 21.6, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.1, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 25.55, + 'wind_speed': 9.05, + }), + dict({ + 'apparent_temperature': 27.0, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T22:00:00Z', + 'dew_point': 21.8, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 24.6, + 'uv_index': 1, + 'wind_bearing': 140, + 'wind_gust_speed': 29.08, + 'wind_speed': 10.37, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T23:00:00Z', + 'dew_point': 21.9, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.36, + 'temperature': 25.9, + 'uv_index': 2, + 'wind_bearing': 140, + 'wind_gust_speed': 34.13, + 'wind_speed': 12.56, + }), + dict({ + 'apparent_temperature': 30.1, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T00:00:00Z', + 'dew_point': 22.3, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 27.2, + 'uv_index': 3, + 'wind_bearing': 140, + 'wind_gust_speed': 38.2, + 'wind_speed': 15.65, + }), + dict({ + 'apparent_temperature': 31.4, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T01:00:00Z', + 'dew_point': 22.3, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.31, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 37.55, + 'wind_speed': 15.78, + }), + dict({ + 'apparent_temperature': 32.7, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T02:00:00Z', + 'dew_point': 22.4, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.98, + 'temperature': 29.6, + 'uv_index': 6, + 'wind_bearing': 143, + 'wind_gust_speed': 35.86, + 'wind_speed': 15.41, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T03:00:00Z', + 'dew_point': 22.5, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.61, + 'temperature': 30.3, + 'uv_index': 6, + 'wind_bearing': 141, + 'wind_gust_speed': 35.88, + 'wind_speed': 15.51, + }), + dict({ + 'apparent_temperature': 33.8, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T04:00:00Z', + 'dew_point': 22.6, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.36, + 'temperature': 30.4, + 'uv_index': 5, + 'wind_bearing': 140, + 'wind_gust_speed': 35.99, + 'wind_speed': 15.75, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T05:00:00Z', + 'dew_point': 22.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.11, + 'temperature': 30.1, + 'uv_index': 4, + 'wind_bearing': 137, + 'wind_gust_speed': 33.61, + 'wind_speed': 15.36, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 77.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T06:00:00Z', + 'dew_point': 22.5, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.98, + 'temperature': 30.0, + 'uv_index': 3, + 'wind_bearing': 138, + 'wind_gust_speed': 32.61, + 'wind_speed': 14.98, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T07:00:00Z', + 'dew_point': 22.2, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.13, + 'temperature': 29.2, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 28.1, + 'wind_speed': 13.88, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T08:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.48, + 'temperature': 28.3, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 24.22, + 'wind_speed': 13.02, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 55.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T09:00:00Z', + 'dew_point': 21.9, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.81, + 'temperature': 27.1, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 22.5, + 'wind_speed': 11.94, + }), + dict({ + 'apparent_temperature': 28.8, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T10:00:00Z', + 'dew_point': 21.7, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 21.47, + 'wind_speed': 11.25, + }), + dict({ + 'apparent_temperature': 28.1, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T11:00:00Z', + 'dew_point': 21.8, + 'humidity': 80, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.77, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 22.71, + 'wind_speed': 12.39, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.97, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 23.67, + 'wind_speed': 12.83, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T13:00:00Z', + 'dew_point': 21.7, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.97, + 'temperature': 24.7, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 23.34, + 'wind_speed': 12.62, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T14:00:00Z', + 'dew_point': 21.7, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.83, + 'temperature': 24.4, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 22.9, + 'wind_speed': 12.07, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T15:00:00Z', + 'dew_point': 21.6, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.74, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 22.01, + 'wind_speed': 11.19, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T16:00:00Z', + 'dew_point': 21.6, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.56, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 21.29, + 'wind_speed': 10.97, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T17:00:00Z', + 'dew_point': 21.5, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.35, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 20.52, + 'wind_speed': 10.5, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T18:00:00Z', + 'dew_point': 21.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.3, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 20.04, + 'wind_speed': 10.51, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T19:00:00Z', + 'dew_point': 21.3, + 'humidity': 88, + 'precipitation': 0.3, + 'precipitation_probability': 12.0, + 'pressure': 1011.37, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 18.07, + 'wind_speed': 10.13, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T20:00:00Z', + 'dew_point': 21.2, + 'humidity': 89, + 'precipitation': 0.2, + 'precipitation_probability': 13.0, + 'pressure': 1011.53, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 16.86, + 'wind_speed': 10.34, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T21:00:00Z', + 'dew_point': 21.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.71, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 16.66, + 'wind_speed': 10.68, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T22:00:00Z', + 'dew_point': 21.9, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.94, + 'temperature': 24.4, + 'uv_index': 1, + 'wind_bearing': 137, + 'wind_gust_speed': 17.21, + 'wind_speed': 10.61, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T23:00:00Z', + 'dew_point': 22.3, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.05, + 'temperature': 25.6, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 19.23, + 'wind_speed': 11.13, + }), + dict({ + 'apparent_temperature': 29.5, + 'cloud_coverage': 79.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T00:00:00Z', + 'dew_point': 22.6, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.07, + 'temperature': 26.6, + 'uv_index': 3, + 'wind_bearing': 140, + 'wind_gust_speed': 20.61, + 'wind_speed': 11.13, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 82.0, + 'condition': 'rainy', + 'datetime': '2023-09-12T01:00:00Z', + 'dew_point': 23.1, + 'humidity': 75, + 'precipitation': 0.2, + 'precipitation_probability': 16.0, + 'pressure': 1011.89, + 'temperature': 27.9, + 'uv_index': 4, + 'wind_bearing': 141, + 'wind_gust_speed': 23.35, + 'wind_speed': 11.98, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T02:00:00Z', + 'dew_point': 23.5, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.53, + 'temperature': 29.0, + 'uv_index': 5, + 'wind_bearing': 143, + 'wind_gust_speed': 26.45, + 'wind_speed': 13.01, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T03:00:00Z', + 'dew_point': 23.5, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.15, + 'temperature': 29.8, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 28.95, + 'wind_speed': 13.9, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T04:00:00Z', + 'dew_point': 23.4, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.79, + 'temperature': 30.2, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 27.9, + 'wind_speed': 13.95, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T05:00:00Z', + 'dew_point': 23.1, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.43, + 'temperature': 30.4, + 'uv_index': 4, + 'wind_bearing': 140, + 'wind_gust_speed': 26.53, + 'wind_speed': 13.78, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T06:00:00Z', + 'dew_point': 22.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.21, + 'temperature': 30.1, + 'uv_index': 3, + 'wind_bearing': 138, + 'wind_gust_speed': 24.56, + 'wind_speed': 13.74, + }), + dict({ + 'apparent_temperature': 32.0, + 'cloud_coverage': 53.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T07:00:00Z', + 'dew_point': 22.1, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.26, + 'temperature': 29.1, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 22.78, + 'wind_speed': 13.21, + }), + dict({ + 'apparent_temperature': 30.9, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.51, + 'temperature': 28.1, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 19.92, + 'wind_speed': 12.0, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 50.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T09:00:00Z', + 'dew_point': 21.7, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.8, + 'temperature': 27.2, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 17.65, + 'wind_speed': 10.97, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T10:00:00Z', + 'dew_point': 21.4, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.23, + 'temperature': 26.2, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 15.87, + 'wind_speed': 10.23, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T11:00:00Z', + 'dew_point': 21.3, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1011.79, + 'temperature': 25.4, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 13.9, + 'wind_speed': 9.39, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T12:00:00Z', + 'dew_point': 21.2, + 'humidity': 81, + 'precipitation': 0.0, + 'precipitation_probability': 47.0, + 'pressure': 1012.12, + 'temperature': 24.7, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 13.32, + 'wind_speed': 8.9, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T13:00:00Z', + 'dew_point': 21.2, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1012.18, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 13.18, + 'wind_speed': 8.59, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T14:00:00Z', + 'dew_point': 21.3, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.09, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 13.84, + 'wind_speed': 8.87, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T15:00:00Z', + 'dew_point': 21.3, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.99, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 15.08, + 'wind_speed': 8.93, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T16:00:00Z', + 'dew_point': 21.0, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 23.2, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 16.74, + 'wind_speed': 9.49, + }), + dict({ + 'apparent_temperature': 24.7, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T17:00:00Z', + 'dew_point': 20.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.75, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 17.45, + 'wind_speed': 9.12, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T18:00:00Z', + 'dew_point': 20.7, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.77, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 17.04, + 'wind_speed': 8.68, + }), + dict({ + 'apparent_temperature': 24.1, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T19:00:00Z', + 'dew_point': 20.6, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 16.8, + 'wind_speed': 8.61, + }), + dict({ + 'apparent_temperature': 23.9, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T20:00:00Z', + 'dew_point': 20.5, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.23, + 'temperature': 22.1, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 15.35, + 'wind_speed': 8.36, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 75.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T21:00:00Z', + 'dew_point': 20.6, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.49, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 14.09, + 'wind_speed': 7.77, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T22:00:00Z', + 'dew_point': 21.0, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.72, + 'temperature': 23.8, + 'uv_index': 1, + 'wind_bearing': 152, + 'wind_gust_speed': 14.04, + 'wind_speed': 7.25, + }), + dict({ + 'apparent_temperature': 27.8, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T23:00:00Z', + 'dew_point': 21.4, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.85, + 'temperature': 25.5, + 'uv_index': 2, + 'wind_bearing': 149, + 'wind_gust_speed': 15.31, + 'wind_speed': 7.14, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-13T00:00:00Z', + 'dew_point': 21.8, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.89, + 'temperature': 27.1, + 'uv_index': 4, + 'wind_bearing': 141, + 'wind_gust_speed': 16.42, + 'wind_speed': 6.89, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T01:00:00Z', + 'dew_point': 22.0, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.65, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 137, + 'wind_gust_speed': 18.64, + 'wind_speed': 6.65, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T02:00:00Z', + 'dew_point': 21.9, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.26, + 'temperature': 29.4, + 'uv_index': 5, + 'wind_bearing': 128, + 'wind_gust_speed': 21.69, + 'wind_speed': 7.12, + }), + dict({ + 'apparent_temperature': 33.0, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T03:00:00Z', + 'dew_point': 21.9, + 'humidity': 62, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.88, + 'temperature': 30.1, + 'uv_index': 6, + 'wind_bearing': 111, + 'wind_gust_speed': 23.41, + 'wind_speed': 7.33, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 72.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T04:00:00Z', + 'dew_point': 22.0, + 'humidity': 61, + 'precipitation': 0.9, + 'precipitation_probability': 12.0, + 'pressure': 1011.55, + 'temperature': 30.4, + 'uv_index': 5, + 'wind_bearing': 56, + 'wind_gust_speed': 23.1, + 'wind_speed': 8.09, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 72.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T05:00:00Z', + 'dew_point': 21.9, + 'humidity': 61, + 'precipitation': 1.9, + 'precipitation_probability': 12.0, + 'pressure': 1011.29, + 'temperature': 30.2, + 'uv_index': 4, + 'wind_bearing': 20, + 'wind_gust_speed': 21.81, + 'wind_speed': 9.46, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 74.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T06:00:00Z', + 'dew_point': 21.9, + 'humidity': 63, + 'precipitation': 2.3, + 'precipitation_probability': 11.0, + 'pressure': 1011.17, + 'temperature': 29.7, + 'uv_index': 3, + 'wind_bearing': 20, + 'wind_gust_speed': 19.72, + 'wind_speed': 9.8, + }), + dict({ + 'apparent_temperature': 31.8, + 'cloud_coverage': 69.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T07:00:00Z', + 'dew_point': 22.4, + 'humidity': 68, + 'precipitation': 1.8, + 'precipitation_probability': 10.0, + 'pressure': 1011.32, + 'temperature': 28.8, + 'uv_index': 1, + 'wind_bearing': 18, + 'wind_gust_speed': 17.55, + 'wind_speed': 9.23, + }), + dict({ + 'apparent_temperature': 30.8, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T08:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.8, + 'precipitation_probability': 10.0, + 'pressure': 1011.6, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 27, + 'wind_gust_speed': 15.08, + 'wind_speed': 8.05, + }), + dict({ + 'apparent_temperature': 29.4, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T09:00:00Z', + 'dew_point': 23.0, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.94, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 32, + 'wind_gust_speed': 12.17, + 'wind_speed': 6.68, + }), + dict({ + 'apparent_temperature': 28.5, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T10:00:00Z', + 'dew_point': 22.9, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.3, + 'temperature': 25.5, + 'uv_index': 0, + 'wind_bearing': 69, + 'wind_gust_speed': 11.64, + 'wind_speed': 6.69, + }), + dict({ + 'apparent_temperature': 27.7, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T11:00:00Z', + 'dew_point': 22.6, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.71, + 'temperature': 25.0, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 11.91, + 'wind_speed': 6.23, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T12:00:00Z', + 'dew_point': 22.3, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.96, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 12.47, + 'wind_speed': 5.73, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T13:00:00Z', + 'dew_point': 22.3, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.03, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 13.57, + 'wind_speed': 5.66, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T14:00:00Z', + 'dew_point': 22.2, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.99, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 15.07, + 'wind_speed': 5.83, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T15:00:00Z', + 'dew_point': 22.2, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.95, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 16.06, + 'wind_speed': 5.93, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T16:00:00Z', + 'dew_point': 22.0, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.9, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 153, + 'wind_gust_speed': 16.05, + 'wind_speed': 5.75, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T17:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.85, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 15.52, + 'wind_speed': 5.49, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 92.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T18:00:00Z', + 'dew_point': 21.8, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.87, + 'temperature': 23.0, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 15.01, + 'wind_speed': 5.32, + }), + dict({ + 'apparent_temperature': 25.0, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T19:00:00Z', + 'dew_point': 21.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.01, + 'temperature': 22.8, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 14.39, + 'wind_speed': 5.33, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T20:00:00Z', + 'dew_point': 21.6, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.22, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 13.79, + 'wind_speed': 5.43, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T21:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.41, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 14.12, + 'wind_speed': 5.52, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 77.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T22:00:00Z', + 'dew_point': 22.1, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.59, + 'temperature': 24.3, + 'uv_index': 1, + 'wind_bearing': 147, + 'wind_gust_speed': 16.14, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T23:00:00Z', + 'dew_point': 22.4, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.74, + 'temperature': 25.7, + 'uv_index': 2, + 'wind_bearing': 146, + 'wind_gust_speed': 19.09, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 30.5, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T00:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.78, + 'temperature': 27.4, + 'uv_index': 4, + 'wind_bearing': 143, + 'wind_gust_speed': 21.6, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 32.2, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T01:00:00Z', + 'dew_point': 23.2, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.61, + 'temperature': 28.7, + 'uv_index': 5, + 'wind_bearing': 138, + 'wind_gust_speed': 23.36, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T02:00:00Z', + 'dew_point': 23.2, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.32, + 'temperature': 29.9, + 'uv_index': 6, + 'wind_bearing': 111, + 'wind_gust_speed': 24.72, + 'wind_speed': 4.99, + }), + dict({ + 'apparent_temperature': 34.4, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T03:00:00Z', + 'dew_point': 23.3, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.04, + 'temperature': 30.7, + 'uv_index': 6, + 'wind_bearing': 354, + 'wind_gust_speed': 25.23, + 'wind_speed': 4.74, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T04:00:00Z', + 'dew_point': 23.4, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.77, + 'temperature': 31.0, + 'uv_index': 6, + 'wind_bearing': 341, + 'wind_gust_speed': 24.6, + 'wind_speed': 4.79, + }), + dict({ + 'apparent_temperature': 34.5, + 'cloud_coverage': 60.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T05:00:00Z', + 'dew_point': 23.2, + 'humidity': 64, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1012.53, + 'temperature': 30.7, + 'uv_index': 5, + 'wind_bearing': 336, + 'wind_gust_speed': 23.28, + 'wind_speed': 5.07, + }), + dict({ + 'apparent_temperature': 33.8, + 'cloud_coverage': 59.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T06:00:00Z', + 'dew_point': 23.1, + 'humidity': 66, + 'precipitation': 0.2, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1012.49, + 'temperature': 30.2, + 'uv_index': 3, + 'wind_bearing': 336, + 'wind_gust_speed': 22.05, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 32.9, + 'cloud_coverage': 53.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T07:00:00Z', + 'dew_point': 23.0, + 'humidity': 68, + 'precipitation': 0.2, + 'precipitation_probability': 40.0, + 'pressure': 1012.73, + 'temperature': 29.5, + 'uv_index': 2, + 'wind_bearing': 339, + 'wind_gust_speed': 21.18, + 'wind_speed': 5.63, + }), + dict({ + 'apparent_temperature': 31.6, + 'cloud_coverage': 43.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T08:00:00Z', + 'dew_point': 22.8, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 45.0, + 'pressure': 1013.16, + 'temperature': 28.4, + 'uv_index': 0, + 'wind_bearing': 342, + 'wind_gust_speed': 20.35, + 'wind_speed': 5.93, + }), + dict({ + 'apparent_temperature': 30.0, + 'cloud_coverage': 35.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T09:00:00Z', + 'dew_point': 22.5, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1013.62, + 'temperature': 27.1, + 'uv_index': 0, + 'wind_bearing': 347, + 'wind_gust_speed': 19.42, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 29.0, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T10:00:00Z', + 'dew_point': 22.4, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.09, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 348, + 'wind_gust_speed': 18.19, + 'wind_speed': 5.31, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T11:00:00Z', + 'dew_point': 22.4, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.56, + 'temperature': 25.5, + 'uv_index': 0, + 'wind_bearing': 177, + 'wind_gust_speed': 16.79, + 'wind_speed': 4.28, + }), + dict({ + 'apparent_temperature': 27.5, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T12:00:00Z', + 'dew_point': 22.3, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.87, + 'temperature': 24.9, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 15.61, + 'wind_speed': 3.72, + }), + dict({ + 'apparent_temperature': 26.6, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T13:00:00Z', + 'dew_point': 22.1, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.91, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 14.7, + 'wind_speed': 4.11, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T14:00:00Z', + 'dew_point': 21.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.8, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 13.81, + 'wind_speed': 4.97, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T15:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.66, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 170, + 'wind_gust_speed': 12.88, + 'wind_speed': 5.57, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 37.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T16:00:00Z', + 'dew_point': 21.5, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.54, + 'temperature': 22.7, + 'uv_index': 0, + 'wind_bearing': 168, + 'wind_gust_speed': 12.0, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 39.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T17:00:00Z', + 'dew_point': 21.3, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.45, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 165, + 'wind_gust_speed': 11.43, + 'wind_speed': 5.48, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T18:00:00Z', + 'dew_point': 21.4, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 44.0, + 'pressure': 1014.45, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 11.42, + 'wind_speed': 5.38, + }), + dict({ + 'apparent_temperature': 25.0, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T19:00:00Z', + 'dew_point': 21.6, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 52.0, + 'pressure': 1014.63, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 12.15, + 'wind_speed': 5.39, + }), + dict({ + 'apparent_temperature': 25.6, + 'cloud_coverage': 38.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T20:00:00Z', + 'dew_point': 21.8, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 51.0, + 'pressure': 1014.91, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 13.54, + 'wind_speed': 5.45, + }), + dict({ + 'apparent_temperature': 26.6, + 'cloud_coverage': 36.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T21:00:00Z', + 'dew_point': 22.0, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 42.0, + 'pressure': 1015.18, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 15.48, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 28.5, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T22:00:00Z', + 'dew_point': 22.5, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 28.999999999999996, + 'pressure': 1015.4, + 'temperature': 25.7, + 'uv_index': 1, + 'wind_bearing': 158, + 'wind_gust_speed': 17.86, + 'wind_speed': 5.84, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 77, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.54, + 'temperature': 27.2, + 'uv_index': 2, + 'wind_bearing': 155, + 'wind_gust_speed': 20.19, + 'wind_speed': 6.09, + }), + dict({ + 'apparent_temperature': 32.1, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-15T00:00:00Z', + 'dew_point': 23.3, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.55, + 'temperature': 28.6, + 'uv_index': 4, + 'wind_bearing': 152, + 'wind_gust_speed': 21.83, + 'wind_speed': 6.42, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-15T01:00:00Z', + 'dew_point': 23.5, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.35, + 'temperature': 29.6, + 'uv_index': 6, + 'wind_bearing': 144, + 'wind_gust_speed': 22.56, + 'wind_speed': 6.91, + }), + dict({ + 'apparent_temperature': 34.2, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T02:00:00Z', + 'dew_point': 23.5, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.0, + 'temperature': 30.4, + 'uv_index': 7, + 'wind_bearing': 336, + 'wind_gust_speed': 22.83, + 'wind_speed': 7.47, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T03:00:00Z', + 'dew_point': 23.5, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.62, + 'temperature': 30.9, + 'uv_index': 7, + 'wind_bearing': 336, + 'wind_gust_speed': 22.98, + 'wind_speed': 7.95, + }), + dict({ + 'apparent_temperature': 35.4, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T04:00:00Z', + 'dew_point': 23.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.25, + 'temperature': 31.3, + 'uv_index': 6, + 'wind_bearing': 341, + 'wind_gust_speed': 23.21, + 'wind_speed': 8.44, + }), + dict({ + 'apparent_temperature': 35.6, + 'cloud_coverage': 44.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T05:00:00Z', + 'dew_point': 23.7, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.95, + 'temperature': 31.5, + 'uv_index': 5, + 'wind_bearing': 344, + 'wind_gust_speed': 23.46, + 'wind_speed': 8.95, + }), + dict({ + 'apparent_temperature': 35.1, + 'cloud_coverage': 42.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T06:00:00Z', + 'dew_point': 23.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.83, + 'temperature': 31.1, + 'uv_index': 3, + 'wind_bearing': 347, + 'wind_gust_speed': 23.64, + 'wind_speed': 9.13, + }), + dict({ + 'apparent_temperature': 34.1, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T07:00:00Z', + 'dew_point': 23.4, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.96, + 'temperature': 30.3, + 'uv_index': 2, + 'wind_bearing': 350, + 'wind_gust_speed': 23.66, + 'wind_speed': 8.78, + }), + dict({ + 'apparent_temperature': 32.4, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T08:00:00Z', + 'dew_point': 23.1, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.25, + 'temperature': 29.0, + 'uv_index': 0, + 'wind_bearing': 356, + 'wind_gust_speed': 23.51, + 'wind_speed': 8.13, + }), + dict({ + 'apparent_temperature': 31.1, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T09:00:00Z', + 'dew_point': 22.9, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.61, + 'temperature': 27.9, + 'uv_index': 0, + 'wind_bearing': 3, + 'wind_gust_speed': 23.21, + 'wind_speed': 7.48, + }), + dict({ + 'apparent_temperature': 30.0, + 'cloud_coverage': 43.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T10:00:00Z', + 'dew_point': 22.8, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.02, + 'temperature': 26.9, + 'uv_index': 0, + 'wind_bearing': 20, + 'wind_gust_speed': 22.68, + 'wind_speed': 6.83, + }), + dict({ + 'apparent_temperature': 29.2, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T11:00:00Z', + 'dew_point': 22.8, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.43, + 'temperature': 26.2, + 'uv_index': 0, + 'wind_bearing': 129, + 'wind_gust_speed': 22.04, + 'wind_speed': 6.1, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T12:00:00Z', + 'dew_point': 22.7, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.71, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 21.64, + 'wind_speed': 5.6, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T13:00:00Z', + 'dew_point': 23.2, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.52, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 164, + 'wind_gust_speed': 16.35, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T14:00:00Z', + 'dew_point': 22.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.37, + 'temperature': 24.6, + 'uv_index': 0, + 'wind_bearing': 168, + 'wind_gust_speed': 17.11, + 'wind_speed': 5.79, + }), + dict({ + 'apparent_temperature': 26.9, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T15:00:00Z', + 'dew_point': 22.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.21, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 17.32, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T16:00:00Z', + 'dew_point': 22.6, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.07, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 201, + 'wind_gust_speed': 16.6, + 'wind_speed': 5.27, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T17:00:00Z', + 'dew_point': 22.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.95, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 219, + 'wind_gust_speed': 15.52, + 'wind_speed': 4.62, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T18:00:00Z', + 'dew_point': 22.3, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.88, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 216, + 'wind_gust_speed': 14.64, + 'wind_speed': 4.32, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T19:00:00Z', + 'dew_point': 22.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.91, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 198, + 'wind_gust_speed': 14.06, + 'wind_speed': 4.73, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T20:00:00Z', + 'dew_point': 22.4, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.99, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 189, + 'wind_gust_speed': 13.7, + 'wind_speed': 5.49, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T21:00:00Z', + 'dew_point': 22.5, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.07, + 'temperature': 24.4, + 'uv_index': 0, + 'wind_bearing': 183, + 'wind_gust_speed': 13.77, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 28.3, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T22:00:00Z', + 'dew_point': 22.6, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.12, + 'temperature': 25.5, + 'uv_index': 1, + 'wind_bearing': 179, + 'wind_gust_speed': 14.38, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 29.9, + 'cloud_coverage': 52.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.13, + 'temperature': 26.9, + 'uv_index': 2, + 'wind_bearing': 170, + 'wind_gust_speed': 15.2, + 'wind_speed': 5.27, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 44.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T00:00:00Z', + 'dew_point': 22.9, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.04, + 'temperature': 28.0, + 'uv_index': 4, + 'wind_bearing': 155, + 'wind_gust_speed': 15.85, + 'wind_speed': 4.76, + }), + dict({ + 'apparent_temperature': 32.5, + 'cloud_coverage': 24.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T01:00:00Z', + 'dew_point': 22.6, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.52, + 'temperature': 29.2, + 'uv_index': 6, + 'wind_bearing': 110, + 'wind_gust_speed': 16.27, + 'wind_speed': 6.81, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 16.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T02:00:00Z', + 'dew_point': 22.4, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.01, + 'temperature': 30.2, + 'uv_index': 8, + 'wind_bearing': 30, + 'wind_gust_speed': 16.55, + 'wind_speed': 6.86, + }), + dict({ + 'apparent_temperature': 34.2, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T03:00:00Z', + 'dew_point': 22.0, + 'humidity': 59, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.45, + 'temperature': 31.1, + 'uv_index': 8, + 'wind_bearing': 17, + 'wind_gust_speed': 16.52, + 'wind_speed': 6.8, + }), + dict({ + 'apparent_temperature': 34.7, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T04:00:00Z', + 'dew_point': 21.9, + 'humidity': 57, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.89, + 'temperature': 31.5, + 'uv_index': 8, + 'wind_bearing': 17, + 'wind_gust_speed': 16.08, + 'wind_speed': 6.62, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T05:00:00Z', + 'dew_point': 21.9, + 'humidity': 56, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.39, + 'temperature': 31.8, + 'uv_index': 6, + 'wind_bearing': 20, + 'wind_gust_speed': 15.48, + 'wind_speed': 6.45, + }), + dict({ + 'apparent_temperature': 34.5, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T06:00:00Z', + 'dew_point': 21.7, + 'humidity': 56, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.11, + 'temperature': 31.4, + 'uv_index': 4, + 'wind_bearing': 26, + 'wind_gust_speed': 15.08, + 'wind_speed': 6.43, + }), + dict({ + 'apparent_temperature': 33.6, + 'cloud_coverage': 7.000000000000001, + 'condition': 'sunny', + 'datetime': '2023-09-16T07:00:00Z', + 'dew_point': 21.7, + 'humidity': 59, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.15, + 'temperature': 30.7, + 'uv_index': 2, + 'wind_bearing': 39, + 'wind_gust_speed': 14.88, + 'wind_speed': 6.61, + }), + dict({ + 'apparent_temperature': 32.5, + 'cloud_coverage': 2.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.41, + 'temperature': 29.6, + 'uv_index': 0, + 'wind_bearing': 72, + 'wind_gust_speed': 14.82, + 'wind_speed': 6.95, + }), + dict({ + 'apparent_temperature': 31.4, + 'cloud_coverage': 2.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T09:00:00Z', + 'dew_point': 22.1, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.75, + 'temperature': 28.5, + 'uv_index': 0, + 'wind_bearing': 116, + 'wind_gust_speed': 15.13, + 'wind_speed': 7.45, + }), + dict({ + 'apparent_temperature': 30.5, + 'cloud_coverage': 13.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T10:00:00Z', + 'dew_point': 22.3, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.13, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 16.09, + 'wind_speed': 8.15, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T11:00:00Z', + 'dew_point': 22.6, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.47, + 'temperature': 26.9, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 17.37, + 'wind_speed': 8.87, + }), + dict({ + 'apparent_temperature': 29.3, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T12:00:00Z', + 'dew_point': 22.9, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.6, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 18.29, + 'wind_speed': 9.21, + }), + dict({ + 'apparent_temperature': 28.7, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T13:00:00Z', + 'dew_point': 23.0, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.41, + 'temperature': 25.7, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 18.49, + 'wind_speed': 8.96, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 55.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T14:00:00Z', + 'dew_point': 22.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.01, + 'temperature': 25.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 18.47, + 'wind_speed': 8.45, + }), + dict({ + 'apparent_temperature': 27.2, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T15:00:00Z', + 'dew_point': 22.7, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.55, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 18.79, + 'wind_speed': 8.1, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T16:00:00Z', + 'dew_point': 22.6, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.1, + 'temperature': 24.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 19.81, + 'wind_speed': 8.15, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T17:00:00Z', + 'dew_point': 22.6, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.68, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 20.96, + 'wind_speed': 8.3, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T18:00:00Z', + 'dew_point': 22.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 21.41, + 'wind_speed': 8.24, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T19:00:00Z', + 'dew_point': 22.5, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 20.42, + 'wind_speed': 7.62, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T20:00:00Z', + 'dew_point': 22.6, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.31, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 18.61, + 'wind_speed': 6.66, + }), + dict({ + 'apparent_temperature': 27.7, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T21:00:00Z', + 'dew_point': 22.6, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.37, + 'temperature': 24.9, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 17.14, + 'wind_speed': 5.86, + }), + dict({ + 'apparent_temperature': 28.9, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T22:00:00Z', + 'dew_point': 22.6, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.46, + 'temperature': 26.0, + 'uv_index': 1, + 'wind_bearing': 161, + 'wind_gust_speed': 16.78, + 'wind_speed': 5.5, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 39.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.51, + 'temperature': 27.5, + 'uv_index': 2, + 'wind_bearing': 165, + 'wind_gust_speed': 17.21, + 'wind_speed': 5.56, + }), + dict({ + 'apparent_temperature': 31.7, + 'cloud_coverage': 33.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T00:00:00Z', + 'dew_point': 22.8, + 'humidity': 71, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 28.5, + 'uv_index': 4, + 'wind_bearing': 174, + 'wind_gust_speed': 17.96, + 'wind_speed': 6.04, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T01:00:00Z', + 'dew_point': 22.7, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.98, + 'temperature': 29.4, + 'uv_index': 6, + 'wind_bearing': 192, + 'wind_gust_speed': 19.15, + 'wind_speed': 7.23, + }), + dict({ + 'apparent_temperature': 33.6, + 'cloud_coverage': 28.999999999999996, + 'condition': 'sunny', + 'datetime': '2023-09-17T02:00:00Z', + 'dew_point': 22.8, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.38, + 'temperature': 30.1, + 'uv_index': 7, + 'wind_bearing': 225, + 'wind_gust_speed': 20.89, + 'wind_speed': 8.9, + }), + dict({ + 'apparent_temperature': 34.1, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T03:00:00Z', + 'dew_point': 22.8, + 'humidity': 63, + 'precipitation': 0.3, + 'precipitation_probability': 9.0, + 'pressure': 1009.75, + 'temperature': 30.7, + 'uv_index': 8, + 'wind_bearing': 264, + 'wind_gust_speed': 22.67, + 'wind_speed': 10.27, + }), + dict({ + 'apparent_temperature': 33.9, + 'cloud_coverage': 37.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T04:00:00Z', + 'dew_point': 22.5, + 'humidity': 62, + 'precipitation': 0.4, + 'precipitation_probability': 10.0, + 'pressure': 1009.18, + 'temperature': 30.5, + 'uv_index': 7, + 'wind_bearing': 293, + 'wind_gust_speed': 23.93, + 'wind_speed': 10.82, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T05:00:00Z', + 'dew_point': 22.4, + 'humidity': 63, + 'precipitation': 0.6, + 'precipitation_probability': 12.0, + 'pressure': 1008.71, + 'temperature': 30.1, + 'uv_index': 5, + 'wind_bearing': 308, + 'wind_gust_speed': 24.39, + 'wind_speed': 10.72, + }), + dict({ + 'apparent_temperature': 32.7, + 'cloud_coverage': 50.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T06:00:00Z', + 'dew_point': 22.2, + 'humidity': 64, + 'precipitation': 0.7, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1008.46, + 'temperature': 29.6, + 'uv_index': 3, + 'wind_bearing': 312, + 'wind_gust_speed': 23.9, + 'wind_speed': 10.28, + }), + dict({ + 'apparent_temperature': 31.8, + 'cloud_coverage': 47.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T07:00:00Z', + 'dew_point': 22.1, + 'humidity': 67, + 'precipitation': 0.7, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1008.53, + 'temperature': 28.9, + 'uv_index': 1, + 'wind_bearing': 312, + 'wind_gust_speed': 22.3, + 'wind_speed': 9.59, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 70, + 'precipitation': 0.6, + 'precipitation_probability': 15.0, + 'pressure': 1008.82, + 'temperature': 27.9, + 'uv_index': 0, + 'wind_bearing': 305, + 'wind_gust_speed': 19.73, + 'wind_speed': 8.58, + }), + dict({ + 'apparent_temperature': 29.6, + 'cloud_coverage': 35.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T09:00:00Z', + 'dew_point': 22.0, + 'humidity': 74, + 'precipitation': 0.5, + 'precipitation_probability': 15.0, + 'pressure': 1009.21, + 'temperature': 27.0, + 'uv_index': 0, + 'wind_bearing': 291, + 'wind_gust_speed': 16.49, + 'wind_speed': 7.34, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 33.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T10:00:00Z', + 'dew_point': 21.9, + 'humidity': 78, + 'precipitation': 0.4, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1009.65, + 'temperature': 26.1, + 'uv_index': 0, + 'wind_bearing': 257, + 'wind_gust_speed': 12.71, + 'wind_speed': 5.91, + }), + dict({ + 'apparent_temperature': 27.8, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T11:00:00Z', + 'dew_point': 21.9, + 'humidity': 82, + 'precipitation': 0.3, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1010.04, + 'temperature': 25.3, + 'uv_index': 0, + 'wind_bearing': 212, + 'wind_gust_speed': 9.16, + 'wind_speed': 4.54, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 36.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T12:00:00Z', + 'dew_point': 21.9, + 'humidity': 85, + 'precipitation': 0.3, + 'precipitation_probability': 28.000000000000004, + 'pressure': 1010.24, + 'temperature': 24.6, + 'uv_index': 0, + 'wind_bearing': 192, + 'wind_gust_speed': 7.09, + 'wind_speed': 3.62, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T13:00:00Z', + 'dew_point': 22.0, + 'humidity': 88, + 'precipitation': 0.3, + 'precipitation_probability': 30.0, + 'pressure': 1010.15, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 185, + 'wind_gust_speed': 7.2, + 'wind_speed': 3.27, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 44.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T14:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.3, + 'precipitation_probability': 30.0, + 'pressure': 1009.87, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 8.37, + 'wind_speed': 3.22, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 49.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T15:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.2, + 'precipitation_probability': 31.0, + 'pressure': 1009.56, + 'temperature': 23.2, + 'uv_index': 0, + 'wind_bearing': 180, + 'wind_gust_speed': 9.21, + 'wind_speed': 3.3, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 53.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T16:00:00Z', + 'dew_point': 21.8, + 'humidity': 94, + 'precipitation': 0.2, + 'precipitation_probability': 33.0, + 'pressure': 1009.29, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 9.0, + 'wind_speed': 3.46, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T17:00:00Z', + 'dew_point': 21.7, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 35.0, + 'pressure': 1009.09, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 186, + 'wind_gust_speed': 8.37, + 'wind_speed': 3.72, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T18:00:00Z', + 'dew_point': 21.6, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 37.0, + 'pressure': 1009.01, + 'temperature': 22.5, + 'uv_index': 0, + 'wind_bearing': 201, + 'wind_gust_speed': 7.99, + 'wind_speed': 4.07, + }), + dict({ + 'apparent_temperature': 24.9, + 'cloud_coverage': 62.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T19:00:00Z', + 'dew_point': 21.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 39.0, + 'pressure': 1009.07, + 'temperature': 22.7, + 'uv_index': 0, + 'wind_bearing': 258, + 'wind_gust_speed': 8.18, + 'wind_speed': 4.55, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T20:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 39.0, + 'pressure': 1009.23, + 'temperature': 23.0, + 'uv_index': 0, + 'wind_bearing': 305, + 'wind_gust_speed': 8.77, + 'wind_speed': 5.17, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T21:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 38.0, + 'pressure': 1009.47, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 318, + 'wind_gust_speed': 9.69, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T22:00:00Z', + 'dew_point': 21.8, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 30.0, + 'pressure': 1009.77, + 'temperature': 24.2, + 'uv_index': 1, + 'wind_bearing': 324, + 'wind_gust_speed': 10.88, + 'wind_speed': 6.26, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 80.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T23:00:00Z', + 'dew_point': 21.9, + 'humidity': 83, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1010.09, + 'temperature': 25.1, + 'uv_index': 2, + 'wind_bearing': 329, + 'wind_gust_speed': 12.21, + 'wind_speed': 6.68, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 87.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T00:00:00Z', + 'dew_point': 21.9, + 'humidity': 80, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1010.33, + 'temperature': 25.7, + 'uv_index': 3, + 'wind_bearing': 332, + 'wind_gust_speed': 13.52, + 'wind_speed': 7.12, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 67.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T01:00:00Z', + 'dew_point': 21.7, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1007.43, + 'temperature': 27.2, + 'uv_index': 5, + 'wind_bearing': 330, + 'wind_gust_speed': 11.36, + 'wind_speed': 11.36, + }), + dict({ + 'apparent_temperature': 30.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T02:00:00Z', + 'dew_point': 21.6, + 'humidity': 70, + 'precipitation': 0.3, + 'precipitation_probability': 9.0, + 'pressure': 1007.05, + 'temperature': 27.5, + 'uv_index': 6, + 'wind_bearing': 332, + 'wind_gust_speed': 12.06, + 'wind_speed': 12.06, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T03:00:00Z', + 'dew_point': 21.6, + 'humidity': 69, + 'precipitation': 0.5, + 'precipitation_probability': 10.0, + 'pressure': 1006.67, + 'temperature': 27.8, + 'uv_index': 6, + 'wind_bearing': 333, + 'wind_gust_speed': 12.81, + 'wind_speed': 12.81, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 67.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T04:00:00Z', + 'dew_point': 21.5, + 'humidity': 68, + 'precipitation': 0.4, + 'precipitation_probability': 10.0, + 'pressure': 1006.28, + 'temperature': 28.0, + 'uv_index': 5, + 'wind_bearing': 335, + 'wind_gust_speed': 13.68, + 'wind_speed': 13.68, + }), + dict({ + 'apparent_temperature': 30.7, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T05:00:00Z', + 'dew_point': 21.4, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1005.89, + 'temperature': 28.1, + 'uv_index': 4, + 'wind_bearing': 336, + 'wind_gust_speed': 14.61, + 'wind_speed': 14.61, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T06:00:00Z', + 'dew_point': 21.2, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 27.0, + 'pressure': 1005.67, + 'temperature': 27.9, + 'uv_index': 3, + 'wind_bearing': 338, + 'wind_gust_speed': 15.25, + 'wind_speed': 15.25, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T07:00:00Z', + 'dew_point': 21.3, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 28.000000000000004, + 'pressure': 1005.74, + 'temperature': 27.4, + 'uv_index': 1, + 'wind_bearing': 339, + 'wind_gust_speed': 15.45, + 'wind_speed': 15.45, + }), + dict({ + 'apparent_temperature': 29.1, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T08:00:00Z', + 'dew_point': 21.4, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1005.98, + 'temperature': 26.7, + 'uv_index': 0, + 'wind_bearing': 341, + 'wind_gust_speed': 15.38, + 'wind_speed': 15.38, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T09:00:00Z', + 'dew_point': 21.6, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1006.22, + 'temperature': 26.1, + 'uv_index': 0, + 'wind_bearing': 341, + 'wind_gust_speed': 15.27, + 'wind_speed': 15.27, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T10:00:00Z', + 'dew_point': 21.6, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1006.44, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 339, + 'wind_gust_speed': 15.09, + 'wind_speed': 15.09, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T11:00:00Z', + 'dew_point': 21.7, + 'humidity': 81, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1006.66, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 336, + 'wind_gust_speed': 14.88, + 'wind_speed': 14.88, + }), + dict({ + 'apparent_temperature': 27.2, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1006.79, + 'temperature': 24.8, + 'uv_index': 0, + 'wind_bearing': 333, + 'wind_gust_speed': 14.91, + 'wind_speed': 14.91, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 38.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T13:00:00Z', + 'dew_point': 21.2, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.36, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 83, + 'wind_gust_speed': 4.58, + 'wind_speed': 3.16, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T14:00:00Z', + 'dew_point': 21.2, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.96, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 4.74, + 'wind_speed': 4.52, + }), + dict({ + 'apparent_temperature': 24.5, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T15:00:00Z', + 'dew_point': 20.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.6, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 152, + 'wind_gust_speed': 5.63, + 'wind_speed': 5.63, + }), + dict({ + 'apparent_temperature': 24.0, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T16:00:00Z', + 'dew_point': 20.7, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.37, + 'temperature': 22.3, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 6.02, + 'wind_speed': 6.02, + }), + dict({ + 'apparent_temperature': 23.7, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T17:00:00Z', + 'dew_point': 20.4, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.2, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 6.15, + 'wind_speed': 6.15, + }), + dict({ + 'apparent_temperature': 23.4, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T18:00:00Z', + 'dew_point': 20.2, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.08, + 'temperature': 21.9, + 'uv_index': 0, + 'wind_bearing': 167, + 'wind_gust_speed': 6.48, + 'wind_speed': 6.48, + }), + dict({ + 'apparent_temperature': 23.2, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T19:00:00Z', + 'dew_point': 19.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.04, + 'temperature': 21.8, + 'uv_index': 0, + 'wind_bearing': 165, + 'wind_gust_speed': 7.51, + 'wind_speed': 7.51, + }), + dict({ + 'apparent_temperature': 23.4, + 'cloud_coverage': 99.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T20:00:00Z', + 'dew_point': 19.6, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.05, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 8.73, + 'wind_speed': 8.73, + }), + dict({ + 'apparent_temperature': 23.9, + 'cloud_coverage': 98.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T21:00:00Z', + 'dew_point': 19.5, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.06, + 'temperature': 22.5, + 'uv_index': 0, + 'wind_bearing': 164, + 'wind_gust_speed': 9.21, + 'wind_speed': 9.11, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 96.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T22:00:00Z', + 'dew_point': 19.7, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.09, + 'temperature': 23.8, + 'uv_index': 1, + 'wind_bearing': 171, + 'wind_gust_speed': 9.03, + 'wind_speed': 7.91, + }), + ]), + }) +# --- +# name: test_hourly_forecast[get_forecasts] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 79.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T14:00:00Z', + 'dew_point': 21.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.24, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 264, + 'wind_gust_speed': 13.44, + 'wind_speed': 6.62, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 80.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T15:00:00Z', + 'dew_point': 21.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.24, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 261, + 'wind_gust_speed': 11.91, + 'wind_speed': 6.64, + }), + dict({ + 'apparent_temperature': 23.8, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T16:00:00Z', + 'dew_point': 21.1, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.12, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 252, + 'wind_gust_speed': 11.15, + 'wind_speed': 6.14, + }), + dict({ + 'apparent_temperature': 23.5, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T17:00:00Z', + 'dew_point': 20.9, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.03, + 'temperature': 21.7, + 'uv_index': 0, + 'wind_bearing': 248, + 'wind_gust_speed': 11.57, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 23.3, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T18:00:00Z', + 'dew_point': 20.8, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.05, + 'temperature': 21.6, + 'uv_index': 0, + 'wind_bearing': 237, + 'wind_gust_speed': 12.42, + 'wind_speed': 5.86, + }), + dict({ + 'apparent_temperature': 23.0, + 'cloud_coverage': 75.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T19:00:00Z', + 'dew_point': 20.6, + 'humidity': 96, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.03, + 'temperature': 21.3, + 'uv_index': 0, + 'wind_bearing': 224, + 'wind_gust_speed': 11.3, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 22.8, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T20:00:00Z', + 'dew_point': 20.4, + 'humidity': 96, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.31, + 'temperature': 21.2, + 'uv_index': 0, + 'wind_bearing': 221, + 'wind_gust_speed': 10.57, + 'wind_speed': 5.13, + }), + dict({ + 'apparent_temperature': 23.1, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-08T21:00:00Z', + 'dew_point': 20.5, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.55, + 'temperature': 21.4, + 'uv_index': 0, + 'wind_bearing': 237, + 'wind_gust_speed': 10.63, + 'wind_speed': 5.7, + }), + dict({ + 'apparent_temperature': 24.9, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-08T22:00:00Z', + 'dew_point': 21.3, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.79, + 'temperature': 22.8, + 'uv_index': 1, + 'wind_bearing': 258, + 'wind_gust_speed': 10.47, + 'wind_speed': 5.22, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T23:00:00Z', + 'dew_point': 21.3, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.95, + 'temperature': 24.0, + 'uv_index': 2, + 'wind_bearing': 282, + 'wind_gust_speed': 12.74, + 'wind_speed': 5.71, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T00:00:00Z', + 'dew_point': 21.5, + 'humidity': 80, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.35, + 'temperature': 25.1, + 'uv_index': 3, + 'wind_bearing': 294, + 'wind_gust_speed': 13.87, + 'wind_speed': 6.53, + }), + dict({ + 'apparent_temperature': 29.0, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T01:00:00Z', + 'dew_point': 21.8, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.48, + 'temperature': 26.5, + 'uv_index': 5, + 'wind_bearing': 308, + 'wind_gust_speed': 16.04, + 'wind_speed': 6.54, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T02:00:00Z', + 'dew_point': 22.0, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.23, + 'temperature': 27.6, + 'uv_index': 6, + 'wind_bearing': 314, + 'wind_gust_speed': 18.1, + 'wind_speed': 7.32, + }), + dict({ + 'apparent_temperature': 31.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T03:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.86, + 'temperature': 28.3, + 'uv_index': 6, + 'wind_bearing': 317, + 'wind_gust_speed': 20.77, + 'wind_speed': 9.1, + }), + dict({ + 'apparent_temperature': 31.5, + 'cloud_coverage': 69.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T04:00:00Z', + 'dew_point': 22.1, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.65, + 'temperature': 28.6, + 'uv_index': 6, + 'wind_bearing': 311, + 'wind_gust_speed': 21.27, + 'wind_speed': 10.21, + }), + dict({ + 'apparent_temperature': 31.3, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T05:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.48, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 317, + 'wind_gust_speed': 19.62, + 'wind_speed': 10.53, + }), + dict({ + 'apparent_temperature': 30.8, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T06:00:00Z', + 'dew_point': 22.2, + 'humidity': 71, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.54, + 'temperature': 27.9, + 'uv_index': 3, + 'wind_bearing': 335, + 'wind_gust_speed': 18.98, + 'wind_speed': 8.63, + }), + dict({ + 'apparent_temperature': 29.9, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T07:00:00Z', + 'dew_point': 22.2, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.76, + 'temperature': 27.1, + 'uv_index': 2, + 'wind_bearing': 338, + 'wind_gust_speed': 17.04, + 'wind_speed': 7.75, + }), + dict({ + 'apparent_temperature': 29.1, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T08:00:00Z', + 'dew_point': 22.1, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.05, + 'temperature': 26.4, + 'uv_index': 0, + 'wind_bearing': 342, + 'wind_gust_speed': 14.75, + 'wind_speed': 6.26, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T09:00:00Z', + 'dew_point': 22.0, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.38, + 'temperature': 25.4, + 'uv_index': 0, + 'wind_bearing': 344, + 'wind_gust_speed': 10.43, + 'wind_speed': 5.2, + }), + dict({ + 'apparent_temperature': 26.9, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T10:00:00Z', + 'dew_point': 21.9, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.73, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 339, + 'wind_gust_speed': 6.95, + 'wind_speed': 3.59, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T11:00:00Z', + 'dew_point': 21.8, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.3, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 326, + 'wind_gust_speed': 5.27, + 'wind_speed': 2.1, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 53.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.52, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 257, + 'wind_gust_speed': 5.48, + 'wind_speed': 0.93, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T13:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.53, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 188, + 'wind_gust_speed': 4.44, + 'wind_speed': 1.79, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T14:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.46, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 183, + 'wind_gust_speed': 4.49, + 'wind_speed': 2.19, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T15:00:00Z', + 'dew_point': 21.4, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.21, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 179, + 'wind_gust_speed': 5.32, + 'wind_speed': 2.65, + }), + dict({ + 'apparent_temperature': 24.0, + 'cloud_coverage': 42.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T16:00:00Z', + 'dew_point': 21.1, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.09, + 'temperature': 22.1, + 'uv_index': 0, + 'wind_bearing': 173, + 'wind_gust_speed': 5.81, + 'wind_speed': 3.2, + }), + dict({ + 'apparent_temperature': 23.7, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T17:00:00Z', + 'dew_point': 20.9, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.88, + 'temperature': 21.9, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 5.53, + 'wind_speed': 3.16, + }), + dict({ + 'apparent_temperature': 23.3, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T18:00:00Z', + 'dew_point': 20.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.94, + 'temperature': 21.6, + 'uv_index': 0, + 'wind_bearing': 153, + 'wind_gust_speed': 6.09, + 'wind_speed': 3.36, + }), + dict({ + 'apparent_temperature': 23.1, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T19:00:00Z', + 'dew_point': 20.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.96, + 'temperature': 21.4, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 6.83, + 'wind_speed': 3.71, + }), + dict({ + 'apparent_temperature': 22.5, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T20:00:00Z', + 'dew_point': 20.0, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 21.0, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 7.98, + 'wind_speed': 4.27, + }), + dict({ + 'apparent_temperature': 22.8, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T21:00:00Z', + 'dew_point': 20.2, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.61, + 'temperature': 21.2, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 8.4, + 'wind_speed': 4.69, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T22:00:00Z', + 'dew_point': 21.3, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.87, + 'temperature': 23.1, + 'uv_index': 1, + 'wind_bearing': 150, + 'wind_gust_speed': 7.66, + 'wind_speed': 4.33, + }), + dict({ + 'apparent_temperature': 28.3, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T23:00:00Z', + 'dew_point': 22.3, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 25.6, + 'uv_index': 2, + 'wind_bearing': 123, + 'wind_gust_speed': 9.63, + 'wind_speed': 3.91, + }), + dict({ + 'apparent_temperature': 30.4, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T00:00:00Z', + 'dew_point': 22.6, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 27.4, + 'uv_index': 4, + 'wind_bearing': 105, + 'wind_gust_speed': 12.59, + 'wind_speed': 3.96, + }), + dict({ + 'apparent_temperature': 32.2, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T01:00:00Z', + 'dew_point': 22.9, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.79, + 'temperature': 28.9, + 'uv_index': 5, + 'wind_bearing': 99, + 'wind_gust_speed': 14.17, + 'wind_speed': 4.06, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 62.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-10T02:00:00Z', + 'dew_point': 22.9, + 'humidity': 66, + 'precipitation': 0.3, + 'precipitation_probability': 7.000000000000001, + 'pressure': 1011.29, + 'temperature': 29.9, + 'uv_index': 6, + 'wind_bearing': 93, + 'wind_gust_speed': 17.75, + 'wind_speed': 4.87, + }), + dict({ + 'apparent_temperature': 34.3, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T03:00:00Z', + 'dew_point': 23.1, + 'humidity': 64, + 'precipitation': 0.3, + 'precipitation_probability': 11.0, + 'pressure': 1010.78, + 'temperature': 30.6, + 'uv_index': 6, + 'wind_bearing': 78, + 'wind_gust_speed': 17.43, + 'wind_speed': 4.54, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 74.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T04:00:00Z', + 'dew_point': 23.2, + 'humidity': 66, + 'precipitation': 0.4, + 'precipitation_probability': 15.0, + 'pressure': 1010.37, + 'temperature': 30.3, + 'uv_index': 5, + 'wind_bearing': 60, + 'wind_gust_speed': 15.24, + 'wind_speed': 4.9, + }), + dict({ + 'apparent_temperature': 33.7, + 'cloud_coverage': 79.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T05:00:00Z', + 'dew_point': 23.3, + 'humidity': 67, + 'precipitation': 0.7, + 'precipitation_probability': 17.0, + 'pressure': 1010.09, + 'temperature': 30.0, + 'uv_index': 4, + 'wind_bearing': 80, + 'wind_gust_speed': 13.53, + 'wind_speed': 5.98, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 80.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T06:00:00Z', + 'dew_point': 23.4, + 'humidity': 70, + 'precipitation': 1.0, + 'precipitation_probability': 17.0, + 'pressure': 1010.0, + 'temperature': 29.5, + 'uv_index': 3, + 'wind_bearing': 83, + 'wind_gust_speed': 12.55, + 'wind_speed': 6.84, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 88.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T07:00:00Z', + 'dew_point': 23.4, + 'humidity': 73, + 'precipitation': 0.4, + 'precipitation_probability': 16.0, + 'pressure': 1010.27, + 'temperature': 28.7, + 'uv_index': 2, + 'wind_bearing': 90, + 'wind_gust_speed': 10.16, + 'wind_speed': 6.07, + }), + dict({ + 'apparent_temperature': 30.9, + 'cloud_coverage': 92.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T08:00:00Z', + 'dew_point': 23.2, + 'humidity': 77, + 'precipitation': 0.5, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1010.71, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 101, + 'wind_gust_speed': 8.18, + 'wind_speed': 4.82, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 93.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T09:00:00Z', + 'dew_point': 23.2, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.9, + 'temperature': 26.5, + 'uv_index': 0, + 'wind_bearing': 128, + 'wind_gust_speed': 8.89, + 'wind_speed': 4.95, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T10:00:00Z', + 'dew_point': 23.0, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.12, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 134, + 'wind_gust_speed': 10.03, + 'wind_speed': 4.52, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 87.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T11:00:00Z', + 'dew_point': 22.8, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.43, + 'temperature': 25.1, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 12.4, + 'wind_speed': 5.41, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T12:00:00Z', + 'dew_point': 22.5, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.58, + 'temperature': 24.8, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 16.36, + 'wind_speed': 6.31, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T13:00:00Z', + 'dew_point': 22.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.55, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 19.66, + 'wind_speed': 7.23, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T14:00:00Z', + 'dew_point': 22.2, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.4, + 'temperature': 24.3, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 21.15, + 'wind_speed': 7.46, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T15:00:00Z', + 'dew_point': 22.0, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.23, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 22.26, + 'wind_speed': 7.84, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T16:00:00Z', + 'dew_point': 21.8, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.01, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 23.53, + 'wind_speed': 8.63, + }), + dict({ + 'apparent_temperature': 25.6, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-10T17:00:00Z', + 'dew_point': 21.6, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.78, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 22.83, + 'wind_speed': 8.61, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T18:00:00Z', + 'dew_point': 21.5, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.69, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 23.7, + 'wind_speed': 8.7, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T19:00:00Z', + 'dew_point': 21.4, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.77, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 24.24, + 'wind_speed': 8.74, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T20:00:00Z', + 'dew_point': 21.6, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.89, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 23.99, + 'wind_speed': 8.81, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T21:00:00Z', + 'dew_point': 21.6, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.1, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 25.55, + 'wind_speed': 9.05, + }), + dict({ + 'apparent_temperature': 27.0, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T22:00:00Z', + 'dew_point': 21.8, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 24.6, + 'uv_index': 1, + 'wind_bearing': 140, + 'wind_gust_speed': 29.08, + 'wind_speed': 10.37, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T23:00:00Z', + 'dew_point': 21.9, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.36, + 'temperature': 25.9, + 'uv_index': 2, + 'wind_bearing': 140, + 'wind_gust_speed': 34.13, + 'wind_speed': 12.56, + }), + dict({ + 'apparent_temperature': 30.1, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T00:00:00Z', + 'dew_point': 22.3, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 27.2, + 'uv_index': 3, + 'wind_bearing': 140, + 'wind_gust_speed': 38.2, + 'wind_speed': 15.65, + }), + dict({ + 'apparent_temperature': 31.4, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T01:00:00Z', + 'dew_point': 22.3, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.31, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 37.55, + 'wind_speed': 15.78, + }), + dict({ + 'apparent_temperature': 32.7, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T02:00:00Z', + 'dew_point': 22.4, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.98, + 'temperature': 29.6, + 'uv_index': 6, + 'wind_bearing': 143, + 'wind_gust_speed': 35.86, + 'wind_speed': 15.41, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T03:00:00Z', + 'dew_point': 22.5, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.61, + 'temperature': 30.3, + 'uv_index': 6, + 'wind_bearing': 141, + 'wind_gust_speed': 35.88, + 'wind_speed': 15.51, + }), + dict({ + 'apparent_temperature': 33.8, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T04:00:00Z', + 'dew_point': 22.6, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.36, + 'temperature': 30.4, + 'uv_index': 5, + 'wind_bearing': 140, + 'wind_gust_speed': 35.99, + 'wind_speed': 15.75, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T05:00:00Z', + 'dew_point': 22.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.11, + 'temperature': 30.1, + 'uv_index': 4, + 'wind_bearing': 137, + 'wind_gust_speed': 33.61, + 'wind_speed': 15.36, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 77.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T06:00:00Z', + 'dew_point': 22.5, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.98, + 'temperature': 30.0, + 'uv_index': 3, + 'wind_bearing': 138, + 'wind_gust_speed': 32.61, + 'wind_speed': 14.98, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T07:00:00Z', + 'dew_point': 22.2, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.13, + 'temperature': 29.2, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 28.1, + 'wind_speed': 13.88, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T08:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.48, + 'temperature': 28.3, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 24.22, + 'wind_speed': 13.02, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 55.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T09:00:00Z', + 'dew_point': 21.9, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.81, + 'temperature': 27.1, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 22.5, + 'wind_speed': 11.94, + }), + dict({ + 'apparent_temperature': 28.8, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T10:00:00Z', + 'dew_point': 21.7, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 21.47, + 'wind_speed': 11.25, + }), + dict({ + 'apparent_temperature': 28.1, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T11:00:00Z', + 'dew_point': 21.8, + 'humidity': 80, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.77, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 22.71, + 'wind_speed': 12.39, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.97, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 23.67, + 'wind_speed': 12.83, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T13:00:00Z', + 'dew_point': 21.7, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.97, + 'temperature': 24.7, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 23.34, + 'wind_speed': 12.62, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T14:00:00Z', + 'dew_point': 21.7, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.83, + 'temperature': 24.4, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 22.9, + 'wind_speed': 12.07, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T15:00:00Z', + 'dew_point': 21.6, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.74, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 22.01, + 'wind_speed': 11.19, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T16:00:00Z', + 'dew_point': 21.6, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.56, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 21.29, + 'wind_speed': 10.97, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T17:00:00Z', + 'dew_point': 21.5, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.35, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 20.52, + 'wind_speed': 10.5, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T18:00:00Z', + 'dew_point': 21.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.3, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 20.04, + 'wind_speed': 10.51, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T19:00:00Z', + 'dew_point': 21.3, + 'humidity': 88, + 'precipitation': 0.3, + 'precipitation_probability': 12.0, + 'pressure': 1011.37, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 18.07, + 'wind_speed': 10.13, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T20:00:00Z', + 'dew_point': 21.2, + 'humidity': 89, + 'precipitation': 0.2, + 'precipitation_probability': 13.0, + 'pressure': 1011.53, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 16.86, + 'wind_speed': 10.34, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T21:00:00Z', + 'dew_point': 21.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.71, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 16.66, + 'wind_speed': 10.68, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T22:00:00Z', + 'dew_point': 21.9, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.94, + 'temperature': 24.4, + 'uv_index': 1, + 'wind_bearing': 137, + 'wind_gust_speed': 17.21, + 'wind_speed': 10.61, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T23:00:00Z', + 'dew_point': 22.3, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.05, + 'temperature': 25.6, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 19.23, + 'wind_speed': 11.13, + }), + dict({ + 'apparent_temperature': 29.5, + 'cloud_coverage': 79.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T00:00:00Z', + 'dew_point': 22.6, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.07, + 'temperature': 26.6, + 'uv_index': 3, + 'wind_bearing': 140, + 'wind_gust_speed': 20.61, + 'wind_speed': 11.13, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 82.0, + 'condition': 'rainy', + 'datetime': '2023-09-12T01:00:00Z', + 'dew_point': 23.1, + 'humidity': 75, + 'precipitation': 0.2, + 'precipitation_probability': 16.0, + 'pressure': 1011.89, + 'temperature': 27.9, + 'uv_index': 4, + 'wind_bearing': 141, + 'wind_gust_speed': 23.35, + 'wind_speed': 11.98, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T02:00:00Z', + 'dew_point': 23.5, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.53, + 'temperature': 29.0, + 'uv_index': 5, + 'wind_bearing': 143, + 'wind_gust_speed': 26.45, + 'wind_speed': 13.01, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T03:00:00Z', + 'dew_point': 23.5, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.15, + 'temperature': 29.8, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 28.95, + 'wind_speed': 13.9, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T04:00:00Z', + 'dew_point': 23.4, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.79, + 'temperature': 30.2, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 27.9, + 'wind_speed': 13.95, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T05:00:00Z', + 'dew_point': 23.1, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.43, + 'temperature': 30.4, + 'uv_index': 4, + 'wind_bearing': 140, + 'wind_gust_speed': 26.53, + 'wind_speed': 13.78, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T06:00:00Z', + 'dew_point': 22.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.21, + 'temperature': 30.1, + 'uv_index': 3, + 'wind_bearing': 138, + 'wind_gust_speed': 24.56, + 'wind_speed': 13.74, + }), + dict({ + 'apparent_temperature': 32.0, + 'cloud_coverage': 53.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T07:00:00Z', + 'dew_point': 22.1, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.26, + 'temperature': 29.1, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 22.78, + 'wind_speed': 13.21, + }), + dict({ + 'apparent_temperature': 30.9, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.51, + 'temperature': 28.1, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 19.92, + 'wind_speed': 12.0, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 50.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T09:00:00Z', + 'dew_point': 21.7, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.8, + 'temperature': 27.2, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 17.65, + 'wind_speed': 10.97, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T10:00:00Z', + 'dew_point': 21.4, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.23, + 'temperature': 26.2, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 15.87, + 'wind_speed': 10.23, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T11:00:00Z', + 'dew_point': 21.3, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1011.79, + 'temperature': 25.4, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 13.9, + 'wind_speed': 9.39, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T12:00:00Z', + 'dew_point': 21.2, + 'humidity': 81, + 'precipitation': 0.0, + 'precipitation_probability': 47.0, + 'pressure': 1012.12, + 'temperature': 24.7, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 13.32, + 'wind_speed': 8.9, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T13:00:00Z', + 'dew_point': 21.2, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1012.18, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 13.18, + 'wind_speed': 8.59, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T14:00:00Z', + 'dew_point': 21.3, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.09, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 13.84, + 'wind_speed': 8.87, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T15:00:00Z', + 'dew_point': 21.3, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.99, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 15.08, + 'wind_speed': 8.93, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T16:00:00Z', + 'dew_point': 21.0, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 23.2, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 16.74, + 'wind_speed': 9.49, + }), + dict({ + 'apparent_temperature': 24.7, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T17:00:00Z', + 'dew_point': 20.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.75, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 17.45, + 'wind_speed': 9.12, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T18:00:00Z', + 'dew_point': 20.7, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.77, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 17.04, + 'wind_speed': 8.68, + }), + dict({ + 'apparent_temperature': 24.1, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T19:00:00Z', + 'dew_point': 20.6, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 16.8, + 'wind_speed': 8.61, + }), + dict({ + 'apparent_temperature': 23.9, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T20:00:00Z', + 'dew_point': 20.5, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.23, + 'temperature': 22.1, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 15.35, + 'wind_speed': 8.36, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 75.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T21:00:00Z', + 'dew_point': 20.6, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.49, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 14.09, + 'wind_speed': 7.77, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T22:00:00Z', + 'dew_point': 21.0, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.72, + 'temperature': 23.8, + 'uv_index': 1, + 'wind_bearing': 152, + 'wind_gust_speed': 14.04, + 'wind_speed': 7.25, + }), + dict({ + 'apparent_temperature': 27.8, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T23:00:00Z', + 'dew_point': 21.4, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.85, + 'temperature': 25.5, + 'uv_index': 2, + 'wind_bearing': 149, + 'wind_gust_speed': 15.31, + 'wind_speed': 7.14, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-13T00:00:00Z', + 'dew_point': 21.8, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.89, + 'temperature': 27.1, + 'uv_index': 4, + 'wind_bearing': 141, + 'wind_gust_speed': 16.42, + 'wind_speed': 6.89, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T01:00:00Z', + 'dew_point': 22.0, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.65, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 137, + 'wind_gust_speed': 18.64, + 'wind_speed': 6.65, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T02:00:00Z', + 'dew_point': 21.9, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.26, + 'temperature': 29.4, + 'uv_index': 5, + 'wind_bearing': 128, + 'wind_gust_speed': 21.69, + 'wind_speed': 7.12, + }), + dict({ + 'apparent_temperature': 33.0, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T03:00:00Z', + 'dew_point': 21.9, + 'humidity': 62, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.88, + 'temperature': 30.1, + 'uv_index': 6, + 'wind_bearing': 111, + 'wind_gust_speed': 23.41, + 'wind_speed': 7.33, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 72.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T04:00:00Z', + 'dew_point': 22.0, + 'humidity': 61, + 'precipitation': 0.9, + 'precipitation_probability': 12.0, + 'pressure': 1011.55, + 'temperature': 30.4, + 'uv_index': 5, + 'wind_bearing': 56, + 'wind_gust_speed': 23.1, + 'wind_speed': 8.09, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 72.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T05:00:00Z', + 'dew_point': 21.9, + 'humidity': 61, + 'precipitation': 1.9, + 'precipitation_probability': 12.0, + 'pressure': 1011.29, + 'temperature': 30.2, + 'uv_index': 4, + 'wind_bearing': 20, + 'wind_gust_speed': 21.81, + 'wind_speed': 9.46, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 74.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T06:00:00Z', + 'dew_point': 21.9, + 'humidity': 63, + 'precipitation': 2.3, + 'precipitation_probability': 11.0, + 'pressure': 1011.17, + 'temperature': 29.7, + 'uv_index': 3, + 'wind_bearing': 20, + 'wind_gust_speed': 19.72, + 'wind_speed': 9.8, + }), + dict({ + 'apparent_temperature': 31.8, + 'cloud_coverage': 69.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T07:00:00Z', + 'dew_point': 22.4, + 'humidity': 68, + 'precipitation': 1.8, + 'precipitation_probability': 10.0, + 'pressure': 1011.32, + 'temperature': 28.8, + 'uv_index': 1, + 'wind_bearing': 18, + 'wind_gust_speed': 17.55, + 'wind_speed': 9.23, + }), + dict({ + 'apparent_temperature': 30.8, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T08:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.8, + 'precipitation_probability': 10.0, + 'pressure': 1011.6, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 27, + 'wind_gust_speed': 15.08, + 'wind_speed': 8.05, + }), + dict({ + 'apparent_temperature': 29.4, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T09:00:00Z', + 'dew_point': 23.0, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.94, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 32, + 'wind_gust_speed': 12.17, + 'wind_speed': 6.68, + }), + dict({ + 'apparent_temperature': 28.5, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T10:00:00Z', + 'dew_point': 22.9, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.3, + 'temperature': 25.5, + 'uv_index': 0, + 'wind_bearing': 69, + 'wind_gust_speed': 11.64, + 'wind_speed': 6.69, + }), + dict({ + 'apparent_temperature': 27.7, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T11:00:00Z', + 'dew_point': 22.6, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.71, + 'temperature': 25.0, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 11.91, + 'wind_speed': 6.23, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T12:00:00Z', + 'dew_point': 22.3, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.96, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 12.47, + 'wind_speed': 5.73, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T13:00:00Z', + 'dew_point': 22.3, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.03, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 13.57, + 'wind_speed': 5.66, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T14:00:00Z', + 'dew_point': 22.2, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.99, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 15.07, + 'wind_speed': 5.83, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T15:00:00Z', + 'dew_point': 22.2, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.95, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 16.06, + 'wind_speed': 5.93, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T16:00:00Z', + 'dew_point': 22.0, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.9, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 153, + 'wind_gust_speed': 16.05, + 'wind_speed': 5.75, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T17:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.85, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 15.52, + 'wind_speed': 5.49, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 92.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T18:00:00Z', + 'dew_point': 21.8, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.87, + 'temperature': 23.0, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 15.01, + 'wind_speed': 5.32, + }), + dict({ + 'apparent_temperature': 25.0, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T19:00:00Z', + 'dew_point': 21.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.01, + 'temperature': 22.8, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 14.39, + 'wind_speed': 5.33, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T20:00:00Z', + 'dew_point': 21.6, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.22, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 13.79, + 'wind_speed': 5.43, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T21:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.41, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 14.12, + 'wind_speed': 5.52, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 77.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T22:00:00Z', + 'dew_point': 22.1, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.59, + 'temperature': 24.3, + 'uv_index': 1, + 'wind_bearing': 147, + 'wind_gust_speed': 16.14, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T23:00:00Z', + 'dew_point': 22.4, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.74, + 'temperature': 25.7, + 'uv_index': 2, + 'wind_bearing': 146, + 'wind_gust_speed': 19.09, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 30.5, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T00:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.78, + 'temperature': 27.4, + 'uv_index': 4, + 'wind_bearing': 143, + 'wind_gust_speed': 21.6, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 32.2, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T01:00:00Z', + 'dew_point': 23.2, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.61, + 'temperature': 28.7, + 'uv_index': 5, + 'wind_bearing': 138, + 'wind_gust_speed': 23.36, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T02:00:00Z', + 'dew_point': 23.2, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.32, + 'temperature': 29.9, + 'uv_index': 6, + 'wind_bearing': 111, + 'wind_gust_speed': 24.72, + 'wind_speed': 4.99, + }), + dict({ + 'apparent_temperature': 34.4, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T03:00:00Z', + 'dew_point': 23.3, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.04, + 'temperature': 30.7, + 'uv_index': 6, + 'wind_bearing': 354, + 'wind_gust_speed': 25.23, + 'wind_speed': 4.74, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T04:00:00Z', + 'dew_point': 23.4, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.77, + 'temperature': 31.0, + 'uv_index': 6, + 'wind_bearing': 341, + 'wind_gust_speed': 24.6, + 'wind_speed': 4.79, + }), + dict({ + 'apparent_temperature': 34.5, + 'cloud_coverage': 60.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T05:00:00Z', + 'dew_point': 23.2, + 'humidity': 64, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1012.53, + 'temperature': 30.7, + 'uv_index': 5, + 'wind_bearing': 336, + 'wind_gust_speed': 23.28, + 'wind_speed': 5.07, + }), + dict({ + 'apparent_temperature': 33.8, + 'cloud_coverage': 59.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T06:00:00Z', + 'dew_point': 23.1, + 'humidity': 66, + 'precipitation': 0.2, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1012.49, + 'temperature': 30.2, + 'uv_index': 3, + 'wind_bearing': 336, + 'wind_gust_speed': 22.05, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 32.9, + 'cloud_coverage': 53.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T07:00:00Z', + 'dew_point': 23.0, + 'humidity': 68, + 'precipitation': 0.2, + 'precipitation_probability': 40.0, + 'pressure': 1012.73, + 'temperature': 29.5, + 'uv_index': 2, + 'wind_bearing': 339, + 'wind_gust_speed': 21.18, + 'wind_speed': 5.63, + }), + dict({ + 'apparent_temperature': 31.6, + 'cloud_coverage': 43.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T08:00:00Z', + 'dew_point': 22.8, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 45.0, + 'pressure': 1013.16, + 'temperature': 28.4, + 'uv_index': 0, + 'wind_bearing': 342, + 'wind_gust_speed': 20.35, + 'wind_speed': 5.93, + }), + dict({ + 'apparent_temperature': 30.0, + 'cloud_coverage': 35.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T09:00:00Z', + 'dew_point': 22.5, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1013.62, + 'temperature': 27.1, + 'uv_index': 0, + 'wind_bearing': 347, + 'wind_gust_speed': 19.42, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 29.0, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T10:00:00Z', + 'dew_point': 22.4, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.09, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 348, + 'wind_gust_speed': 18.19, + 'wind_speed': 5.31, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T11:00:00Z', + 'dew_point': 22.4, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.56, + 'temperature': 25.5, + 'uv_index': 0, + 'wind_bearing': 177, + 'wind_gust_speed': 16.79, + 'wind_speed': 4.28, + }), + dict({ + 'apparent_temperature': 27.5, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T12:00:00Z', + 'dew_point': 22.3, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.87, + 'temperature': 24.9, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 15.61, + 'wind_speed': 3.72, + }), + dict({ + 'apparent_temperature': 26.6, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T13:00:00Z', + 'dew_point': 22.1, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.91, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 14.7, + 'wind_speed': 4.11, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T14:00:00Z', + 'dew_point': 21.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.8, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 13.81, + 'wind_speed': 4.97, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T15:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.66, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 170, + 'wind_gust_speed': 12.88, + 'wind_speed': 5.57, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 37.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T16:00:00Z', + 'dew_point': 21.5, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.54, + 'temperature': 22.7, + 'uv_index': 0, + 'wind_bearing': 168, + 'wind_gust_speed': 12.0, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 39.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T17:00:00Z', + 'dew_point': 21.3, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.45, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 165, + 'wind_gust_speed': 11.43, + 'wind_speed': 5.48, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T18:00:00Z', + 'dew_point': 21.4, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 44.0, + 'pressure': 1014.45, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 11.42, + 'wind_speed': 5.38, + }), + dict({ + 'apparent_temperature': 25.0, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T19:00:00Z', + 'dew_point': 21.6, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 52.0, + 'pressure': 1014.63, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 12.15, + 'wind_speed': 5.39, + }), + dict({ + 'apparent_temperature': 25.6, + 'cloud_coverage': 38.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T20:00:00Z', + 'dew_point': 21.8, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 51.0, + 'pressure': 1014.91, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 13.54, + 'wind_speed': 5.45, + }), + dict({ + 'apparent_temperature': 26.6, + 'cloud_coverage': 36.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T21:00:00Z', + 'dew_point': 22.0, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 42.0, + 'pressure': 1015.18, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 15.48, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 28.5, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T22:00:00Z', + 'dew_point': 22.5, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 28.999999999999996, + 'pressure': 1015.4, + 'temperature': 25.7, + 'uv_index': 1, + 'wind_bearing': 158, + 'wind_gust_speed': 17.86, + 'wind_speed': 5.84, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 77, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.54, + 'temperature': 27.2, + 'uv_index': 2, + 'wind_bearing': 155, + 'wind_gust_speed': 20.19, + 'wind_speed': 6.09, + }), + dict({ + 'apparent_temperature': 32.1, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-15T00:00:00Z', + 'dew_point': 23.3, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.55, + 'temperature': 28.6, + 'uv_index': 4, + 'wind_bearing': 152, + 'wind_gust_speed': 21.83, + 'wind_speed': 6.42, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-15T01:00:00Z', + 'dew_point': 23.5, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.35, + 'temperature': 29.6, + 'uv_index': 6, + 'wind_bearing': 144, + 'wind_gust_speed': 22.56, + 'wind_speed': 6.91, + }), + dict({ + 'apparent_temperature': 34.2, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T02:00:00Z', + 'dew_point': 23.5, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.0, + 'temperature': 30.4, + 'uv_index': 7, + 'wind_bearing': 336, + 'wind_gust_speed': 22.83, + 'wind_speed': 7.47, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T03:00:00Z', + 'dew_point': 23.5, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.62, + 'temperature': 30.9, + 'uv_index': 7, + 'wind_bearing': 336, + 'wind_gust_speed': 22.98, + 'wind_speed': 7.95, + }), + dict({ + 'apparent_temperature': 35.4, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T04:00:00Z', + 'dew_point': 23.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.25, + 'temperature': 31.3, + 'uv_index': 6, + 'wind_bearing': 341, + 'wind_gust_speed': 23.21, + 'wind_speed': 8.44, + }), + dict({ + 'apparent_temperature': 35.6, + 'cloud_coverage': 44.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T05:00:00Z', + 'dew_point': 23.7, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.95, + 'temperature': 31.5, + 'uv_index': 5, + 'wind_bearing': 344, + 'wind_gust_speed': 23.46, + 'wind_speed': 8.95, + }), + dict({ + 'apparent_temperature': 35.1, + 'cloud_coverage': 42.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T06:00:00Z', + 'dew_point': 23.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.83, + 'temperature': 31.1, + 'uv_index': 3, + 'wind_bearing': 347, + 'wind_gust_speed': 23.64, + 'wind_speed': 9.13, + }), + dict({ + 'apparent_temperature': 34.1, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T07:00:00Z', + 'dew_point': 23.4, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.96, + 'temperature': 30.3, + 'uv_index': 2, + 'wind_bearing': 350, + 'wind_gust_speed': 23.66, + 'wind_speed': 8.78, + }), + dict({ + 'apparent_temperature': 32.4, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T08:00:00Z', + 'dew_point': 23.1, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.25, + 'temperature': 29.0, + 'uv_index': 0, + 'wind_bearing': 356, + 'wind_gust_speed': 23.51, + 'wind_speed': 8.13, + }), + dict({ + 'apparent_temperature': 31.1, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T09:00:00Z', + 'dew_point': 22.9, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.61, + 'temperature': 27.9, + 'uv_index': 0, + 'wind_bearing': 3, + 'wind_gust_speed': 23.21, + 'wind_speed': 7.48, + }), + dict({ + 'apparent_temperature': 30.0, + 'cloud_coverage': 43.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T10:00:00Z', + 'dew_point': 22.8, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.02, + 'temperature': 26.9, + 'uv_index': 0, + 'wind_bearing': 20, + 'wind_gust_speed': 22.68, + 'wind_speed': 6.83, + }), + dict({ + 'apparent_temperature': 29.2, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T11:00:00Z', + 'dew_point': 22.8, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.43, + 'temperature': 26.2, + 'uv_index': 0, + 'wind_bearing': 129, + 'wind_gust_speed': 22.04, + 'wind_speed': 6.1, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T12:00:00Z', + 'dew_point': 22.7, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.71, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 21.64, + 'wind_speed': 5.6, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T13:00:00Z', + 'dew_point': 23.2, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.52, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 164, + 'wind_gust_speed': 16.35, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T14:00:00Z', + 'dew_point': 22.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.37, + 'temperature': 24.6, + 'uv_index': 0, + 'wind_bearing': 168, + 'wind_gust_speed': 17.11, + 'wind_speed': 5.79, + }), + dict({ + 'apparent_temperature': 26.9, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T15:00:00Z', + 'dew_point': 22.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.21, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 17.32, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T16:00:00Z', + 'dew_point': 22.6, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.07, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 201, + 'wind_gust_speed': 16.6, + 'wind_speed': 5.27, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T17:00:00Z', + 'dew_point': 22.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.95, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 219, + 'wind_gust_speed': 15.52, + 'wind_speed': 4.62, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T18:00:00Z', + 'dew_point': 22.3, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.88, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 216, + 'wind_gust_speed': 14.64, + 'wind_speed': 4.32, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T19:00:00Z', + 'dew_point': 22.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.91, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 198, + 'wind_gust_speed': 14.06, + 'wind_speed': 4.73, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T20:00:00Z', + 'dew_point': 22.4, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.99, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 189, + 'wind_gust_speed': 13.7, + 'wind_speed': 5.49, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T21:00:00Z', + 'dew_point': 22.5, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.07, + 'temperature': 24.4, + 'uv_index': 0, + 'wind_bearing': 183, + 'wind_gust_speed': 13.77, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 28.3, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T22:00:00Z', + 'dew_point': 22.6, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.12, + 'temperature': 25.5, + 'uv_index': 1, + 'wind_bearing': 179, + 'wind_gust_speed': 14.38, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 29.9, + 'cloud_coverage': 52.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.13, + 'temperature': 26.9, + 'uv_index': 2, + 'wind_bearing': 170, + 'wind_gust_speed': 15.2, + 'wind_speed': 5.27, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 44.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T00:00:00Z', + 'dew_point': 22.9, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.04, + 'temperature': 28.0, + 'uv_index': 4, + 'wind_bearing': 155, + 'wind_gust_speed': 15.85, + 'wind_speed': 4.76, + }), + dict({ + 'apparent_temperature': 32.5, + 'cloud_coverage': 24.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T01:00:00Z', + 'dew_point': 22.6, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.52, + 'temperature': 29.2, + 'uv_index': 6, + 'wind_bearing': 110, + 'wind_gust_speed': 16.27, + 'wind_speed': 6.81, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 16.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T02:00:00Z', + 'dew_point': 22.4, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.01, + 'temperature': 30.2, + 'uv_index': 8, + 'wind_bearing': 30, + 'wind_gust_speed': 16.55, + 'wind_speed': 6.86, + }), + dict({ + 'apparent_temperature': 34.2, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T03:00:00Z', + 'dew_point': 22.0, + 'humidity': 59, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.45, + 'temperature': 31.1, + 'uv_index': 8, + 'wind_bearing': 17, + 'wind_gust_speed': 16.52, + 'wind_speed': 6.8, + }), + dict({ + 'apparent_temperature': 34.7, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T04:00:00Z', + 'dew_point': 21.9, + 'humidity': 57, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.89, + 'temperature': 31.5, + 'uv_index': 8, + 'wind_bearing': 17, + 'wind_gust_speed': 16.08, + 'wind_speed': 6.62, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T05:00:00Z', + 'dew_point': 21.9, + 'humidity': 56, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.39, + 'temperature': 31.8, + 'uv_index': 6, + 'wind_bearing': 20, + 'wind_gust_speed': 15.48, + 'wind_speed': 6.45, + }), + dict({ + 'apparent_temperature': 34.5, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T06:00:00Z', + 'dew_point': 21.7, + 'humidity': 56, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.11, + 'temperature': 31.4, + 'uv_index': 4, + 'wind_bearing': 26, + 'wind_gust_speed': 15.08, + 'wind_speed': 6.43, + }), + dict({ + 'apparent_temperature': 33.6, + 'cloud_coverage': 7.000000000000001, + 'condition': 'sunny', + 'datetime': '2023-09-16T07:00:00Z', + 'dew_point': 21.7, + 'humidity': 59, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.15, + 'temperature': 30.7, + 'uv_index': 2, + 'wind_bearing': 39, + 'wind_gust_speed': 14.88, + 'wind_speed': 6.61, + }), + dict({ + 'apparent_temperature': 32.5, + 'cloud_coverage': 2.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.41, + 'temperature': 29.6, + 'uv_index': 0, + 'wind_bearing': 72, + 'wind_gust_speed': 14.82, + 'wind_speed': 6.95, + }), + dict({ + 'apparent_temperature': 31.4, + 'cloud_coverage': 2.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T09:00:00Z', + 'dew_point': 22.1, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.75, + 'temperature': 28.5, + 'uv_index': 0, + 'wind_bearing': 116, + 'wind_gust_speed': 15.13, + 'wind_speed': 7.45, + }), + dict({ + 'apparent_temperature': 30.5, + 'cloud_coverage': 13.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T10:00:00Z', + 'dew_point': 22.3, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.13, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 16.09, + 'wind_speed': 8.15, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T11:00:00Z', + 'dew_point': 22.6, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.47, + 'temperature': 26.9, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 17.37, + 'wind_speed': 8.87, + }), + dict({ + 'apparent_temperature': 29.3, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T12:00:00Z', + 'dew_point': 22.9, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.6, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 18.29, + 'wind_speed': 9.21, + }), + dict({ + 'apparent_temperature': 28.7, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T13:00:00Z', + 'dew_point': 23.0, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.41, + 'temperature': 25.7, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 18.49, + 'wind_speed': 8.96, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 55.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T14:00:00Z', + 'dew_point': 22.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.01, + 'temperature': 25.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 18.47, + 'wind_speed': 8.45, + }), + dict({ + 'apparent_temperature': 27.2, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T15:00:00Z', + 'dew_point': 22.7, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.55, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 18.79, + 'wind_speed': 8.1, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T16:00:00Z', + 'dew_point': 22.6, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.1, + 'temperature': 24.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 19.81, + 'wind_speed': 8.15, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T17:00:00Z', + 'dew_point': 22.6, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.68, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 20.96, + 'wind_speed': 8.3, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T18:00:00Z', + 'dew_point': 22.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 21.41, + 'wind_speed': 8.24, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T19:00:00Z', + 'dew_point': 22.5, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 20.42, + 'wind_speed': 7.62, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T20:00:00Z', + 'dew_point': 22.6, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.31, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 18.61, + 'wind_speed': 6.66, + }), + dict({ + 'apparent_temperature': 27.7, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T21:00:00Z', + 'dew_point': 22.6, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.37, + 'temperature': 24.9, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 17.14, + 'wind_speed': 5.86, + }), + dict({ + 'apparent_temperature': 28.9, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T22:00:00Z', + 'dew_point': 22.6, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.46, + 'temperature': 26.0, + 'uv_index': 1, + 'wind_bearing': 161, + 'wind_gust_speed': 16.78, + 'wind_speed': 5.5, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 39.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.51, + 'temperature': 27.5, + 'uv_index': 2, + 'wind_bearing': 165, + 'wind_gust_speed': 17.21, + 'wind_speed': 5.56, + }), + dict({ + 'apparent_temperature': 31.7, + 'cloud_coverage': 33.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T00:00:00Z', + 'dew_point': 22.8, + 'humidity': 71, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 28.5, + 'uv_index': 4, + 'wind_bearing': 174, + 'wind_gust_speed': 17.96, + 'wind_speed': 6.04, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T01:00:00Z', + 'dew_point': 22.7, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.98, + 'temperature': 29.4, + 'uv_index': 6, + 'wind_bearing': 192, + 'wind_gust_speed': 19.15, + 'wind_speed': 7.23, + }), + dict({ + 'apparent_temperature': 33.6, + 'cloud_coverage': 28.999999999999996, + 'condition': 'sunny', + 'datetime': '2023-09-17T02:00:00Z', + 'dew_point': 22.8, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.38, + 'temperature': 30.1, + 'uv_index': 7, + 'wind_bearing': 225, + 'wind_gust_speed': 20.89, + 'wind_speed': 8.9, + }), + dict({ + 'apparent_temperature': 34.1, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T03:00:00Z', + 'dew_point': 22.8, + 'humidity': 63, + 'precipitation': 0.3, + 'precipitation_probability': 9.0, + 'pressure': 1009.75, + 'temperature': 30.7, + 'uv_index': 8, + 'wind_bearing': 264, + 'wind_gust_speed': 22.67, + 'wind_speed': 10.27, + }), + dict({ + 'apparent_temperature': 33.9, + 'cloud_coverage': 37.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T04:00:00Z', + 'dew_point': 22.5, + 'humidity': 62, + 'precipitation': 0.4, + 'precipitation_probability': 10.0, + 'pressure': 1009.18, + 'temperature': 30.5, + 'uv_index': 7, + 'wind_bearing': 293, + 'wind_gust_speed': 23.93, + 'wind_speed': 10.82, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T05:00:00Z', + 'dew_point': 22.4, + 'humidity': 63, + 'precipitation': 0.6, + 'precipitation_probability': 12.0, + 'pressure': 1008.71, + 'temperature': 30.1, + 'uv_index': 5, + 'wind_bearing': 308, + 'wind_gust_speed': 24.39, + 'wind_speed': 10.72, + }), + dict({ + 'apparent_temperature': 32.7, + 'cloud_coverage': 50.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T06:00:00Z', + 'dew_point': 22.2, + 'humidity': 64, + 'precipitation': 0.7, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1008.46, + 'temperature': 29.6, + 'uv_index': 3, + 'wind_bearing': 312, + 'wind_gust_speed': 23.9, + 'wind_speed': 10.28, + }), + dict({ + 'apparent_temperature': 31.8, + 'cloud_coverage': 47.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T07:00:00Z', + 'dew_point': 22.1, + 'humidity': 67, + 'precipitation': 0.7, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1008.53, + 'temperature': 28.9, + 'uv_index': 1, + 'wind_bearing': 312, + 'wind_gust_speed': 22.3, + 'wind_speed': 9.59, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 70, + 'precipitation': 0.6, + 'precipitation_probability': 15.0, + 'pressure': 1008.82, + 'temperature': 27.9, + 'uv_index': 0, + 'wind_bearing': 305, + 'wind_gust_speed': 19.73, + 'wind_speed': 8.58, + }), + dict({ + 'apparent_temperature': 29.6, + 'cloud_coverage': 35.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T09:00:00Z', + 'dew_point': 22.0, + 'humidity': 74, + 'precipitation': 0.5, + 'precipitation_probability': 15.0, + 'pressure': 1009.21, + 'temperature': 27.0, + 'uv_index': 0, + 'wind_bearing': 291, + 'wind_gust_speed': 16.49, + 'wind_speed': 7.34, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 33.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T10:00:00Z', + 'dew_point': 21.9, + 'humidity': 78, + 'precipitation': 0.4, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1009.65, + 'temperature': 26.1, + 'uv_index': 0, + 'wind_bearing': 257, + 'wind_gust_speed': 12.71, + 'wind_speed': 5.91, + }), + dict({ + 'apparent_temperature': 27.8, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T11:00:00Z', + 'dew_point': 21.9, + 'humidity': 82, + 'precipitation': 0.3, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1010.04, + 'temperature': 25.3, + 'uv_index': 0, + 'wind_bearing': 212, + 'wind_gust_speed': 9.16, + 'wind_speed': 4.54, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 36.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T12:00:00Z', + 'dew_point': 21.9, + 'humidity': 85, + 'precipitation': 0.3, + 'precipitation_probability': 28.000000000000004, + 'pressure': 1010.24, + 'temperature': 24.6, + 'uv_index': 0, + 'wind_bearing': 192, + 'wind_gust_speed': 7.09, + 'wind_speed': 3.62, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T13:00:00Z', + 'dew_point': 22.0, + 'humidity': 88, + 'precipitation': 0.3, + 'precipitation_probability': 30.0, + 'pressure': 1010.15, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 185, + 'wind_gust_speed': 7.2, + 'wind_speed': 3.27, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 44.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T14:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.3, + 'precipitation_probability': 30.0, + 'pressure': 1009.87, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 8.37, + 'wind_speed': 3.22, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 49.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T15:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.2, + 'precipitation_probability': 31.0, + 'pressure': 1009.56, + 'temperature': 23.2, + 'uv_index': 0, + 'wind_bearing': 180, + 'wind_gust_speed': 9.21, + 'wind_speed': 3.3, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 53.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T16:00:00Z', + 'dew_point': 21.8, + 'humidity': 94, + 'precipitation': 0.2, + 'precipitation_probability': 33.0, + 'pressure': 1009.29, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 9.0, + 'wind_speed': 3.46, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T17:00:00Z', + 'dew_point': 21.7, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 35.0, + 'pressure': 1009.09, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 186, + 'wind_gust_speed': 8.37, + 'wind_speed': 3.72, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T18:00:00Z', + 'dew_point': 21.6, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 37.0, + 'pressure': 1009.01, + 'temperature': 22.5, + 'uv_index': 0, + 'wind_bearing': 201, + 'wind_gust_speed': 7.99, + 'wind_speed': 4.07, + }), + dict({ + 'apparent_temperature': 24.9, + 'cloud_coverage': 62.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T19:00:00Z', + 'dew_point': 21.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 39.0, + 'pressure': 1009.07, + 'temperature': 22.7, + 'uv_index': 0, + 'wind_bearing': 258, + 'wind_gust_speed': 8.18, + 'wind_speed': 4.55, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T20:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 39.0, + 'pressure': 1009.23, + 'temperature': 23.0, + 'uv_index': 0, + 'wind_bearing': 305, + 'wind_gust_speed': 8.77, + 'wind_speed': 5.17, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T21:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 38.0, + 'pressure': 1009.47, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 318, + 'wind_gust_speed': 9.69, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T22:00:00Z', + 'dew_point': 21.8, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 30.0, + 'pressure': 1009.77, + 'temperature': 24.2, + 'uv_index': 1, + 'wind_bearing': 324, + 'wind_gust_speed': 10.88, + 'wind_speed': 6.26, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 80.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T23:00:00Z', + 'dew_point': 21.9, + 'humidity': 83, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1010.09, + 'temperature': 25.1, + 'uv_index': 2, + 'wind_bearing': 329, + 'wind_gust_speed': 12.21, + 'wind_speed': 6.68, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 87.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T00:00:00Z', + 'dew_point': 21.9, + 'humidity': 80, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1010.33, + 'temperature': 25.7, + 'uv_index': 3, + 'wind_bearing': 332, + 'wind_gust_speed': 13.52, + 'wind_speed': 7.12, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 67.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T01:00:00Z', + 'dew_point': 21.7, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1007.43, + 'temperature': 27.2, + 'uv_index': 5, + 'wind_bearing': 330, + 'wind_gust_speed': 11.36, + 'wind_speed': 11.36, + }), + dict({ + 'apparent_temperature': 30.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T02:00:00Z', + 'dew_point': 21.6, + 'humidity': 70, + 'precipitation': 0.3, + 'precipitation_probability': 9.0, + 'pressure': 1007.05, + 'temperature': 27.5, + 'uv_index': 6, + 'wind_bearing': 332, + 'wind_gust_speed': 12.06, + 'wind_speed': 12.06, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T03:00:00Z', + 'dew_point': 21.6, + 'humidity': 69, + 'precipitation': 0.5, + 'precipitation_probability': 10.0, + 'pressure': 1006.67, + 'temperature': 27.8, + 'uv_index': 6, + 'wind_bearing': 333, + 'wind_gust_speed': 12.81, + 'wind_speed': 12.81, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 67.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T04:00:00Z', + 'dew_point': 21.5, + 'humidity': 68, + 'precipitation': 0.4, + 'precipitation_probability': 10.0, + 'pressure': 1006.28, + 'temperature': 28.0, + 'uv_index': 5, + 'wind_bearing': 335, + 'wind_gust_speed': 13.68, + 'wind_speed': 13.68, + }), + dict({ + 'apparent_temperature': 30.7, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T05:00:00Z', + 'dew_point': 21.4, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1005.89, + 'temperature': 28.1, + 'uv_index': 4, + 'wind_bearing': 336, + 'wind_gust_speed': 14.61, + 'wind_speed': 14.61, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T06:00:00Z', + 'dew_point': 21.2, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 27.0, + 'pressure': 1005.67, + 'temperature': 27.9, + 'uv_index': 3, + 'wind_bearing': 338, + 'wind_gust_speed': 15.25, + 'wind_speed': 15.25, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T07:00:00Z', + 'dew_point': 21.3, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 28.000000000000004, + 'pressure': 1005.74, + 'temperature': 27.4, + 'uv_index': 1, + 'wind_bearing': 339, + 'wind_gust_speed': 15.45, + 'wind_speed': 15.45, + }), + dict({ + 'apparent_temperature': 29.1, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T08:00:00Z', + 'dew_point': 21.4, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1005.98, + 'temperature': 26.7, + 'uv_index': 0, + 'wind_bearing': 341, + 'wind_gust_speed': 15.38, + 'wind_speed': 15.38, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T09:00:00Z', + 'dew_point': 21.6, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1006.22, + 'temperature': 26.1, + 'uv_index': 0, + 'wind_bearing': 341, + 'wind_gust_speed': 15.27, + 'wind_speed': 15.27, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T10:00:00Z', + 'dew_point': 21.6, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1006.44, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 339, + 'wind_gust_speed': 15.09, + 'wind_speed': 15.09, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T11:00:00Z', + 'dew_point': 21.7, + 'humidity': 81, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1006.66, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 336, + 'wind_gust_speed': 14.88, + 'wind_speed': 14.88, + }), + dict({ + 'apparent_temperature': 27.2, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1006.79, + 'temperature': 24.8, + 'uv_index': 0, + 'wind_bearing': 333, + 'wind_gust_speed': 14.91, + 'wind_speed': 14.91, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 38.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T13:00:00Z', + 'dew_point': 21.2, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.36, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 83, + 'wind_gust_speed': 4.58, + 'wind_speed': 3.16, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T14:00:00Z', + 'dew_point': 21.2, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.96, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 4.74, + 'wind_speed': 4.52, + }), + dict({ + 'apparent_temperature': 24.5, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T15:00:00Z', + 'dew_point': 20.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.6, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 152, + 'wind_gust_speed': 5.63, + 'wind_speed': 5.63, + }), + dict({ + 'apparent_temperature': 24.0, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T16:00:00Z', + 'dew_point': 20.7, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.37, + 'temperature': 22.3, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 6.02, + 'wind_speed': 6.02, + }), + dict({ + 'apparent_temperature': 23.7, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T17:00:00Z', + 'dew_point': 20.4, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.2, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 6.15, + 'wind_speed': 6.15, + }), + dict({ + 'apparent_temperature': 23.4, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T18:00:00Z', + 'dew_point': 20.2, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.08, + 'temperature': 21.9, + 'uv_index': 0, + 'wind_bearing': 167, + 'wind_gust_speed': 6.48, + 'wind_speed': 6.48, + }), + dict({ + 'apparent_temperature': 23.2, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T19:00:00Z', + 'dew_point': 19.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.04, + 'temperature': 21.8, + 'uv_index': 0, + 'wind_bearing': 165, + 'wind_gust_speed': 7.51, + 'wind_speed': 7.51, + }), + dict({ + 'apparent_temperature': 23.4, + 'cloud_coverage': 99.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T20:00:00Z', + 'dew_point': 19.6, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.05, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 8.73, + 'wind_speed': 8.73, + }), + dict({ + 'apparent_temperature': 23.9, + 'cloud_coverage': 98.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T21:00:00Z', + 'dew_point': 19.5, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.06, + 'temperature': 22.5, + 'uv_index': 0, + 'wind_bearing': 164, + 'wind_gust_speed': 9.21, + 'wind_speed': 9.11, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 96.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T22:00:00Z', + 'dew_point': 19.7, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.09, + 'temperature': 23.8, + 'uv_index': 1, + 'wind_bearing': 171, + 'wind_gust_speed': 9.03, + 'wind_speed': 7.91, + }), + ]), + }), + }) +# --- diff --git a/tests/components/weatherkit/test_weather.py b/tests/components/weatherkit/test_weather.py index fabd3aab572..3b3a9a50d7f 100644 --- a/tests/components/weatherkit/test_weather.py +++ b/tests/components/weatherkit/test_weather.py @@ -1,5 +1,6 @@ """Weather entity tests for the WeatherKit integration.""" +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.weather import ( @@ -15,7 +16,8 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, DOMAIN as WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + LEGACY_SERVICE_GET_FORECAST, + SERVICE_GET_FORECASTS, ) from homeassistant.components.weather.const import WeatherEntityFeature from homeassistant.components.weatherkit.const import ATTRIBUTION @@ -77,15 +79,22 @@ async def test_hourly_forecast_missing(hass: HomeAssistant) -> None: ) == 0 +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) async def test_hourly_forecast( - hass: HomeAssistant, snapshot: SnapshotAssertion + hass: HomeAssistant, snapshot: SnapshotAssertion, service: str ) -> None: """Test states of the hourly forecast.""" await init_integration(hass) response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.home", "type": "hourly", @@ -93,17 +102,25 @@ async def test_hourly_forecast( blocking=True, return_response=True, ) - assert response["forecast"] != [] assert response == snapshot -async def test_daily_forecast(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_GET_FORECASTS, + LEGACY_SERVICE_GET_FORECAST, + ], +) +async def test_daily_forecast( + hass: HomeAssistant, snapshot: SnapshotAssertion, service: str +) -> None: """Test states of the daily forecast.""" await init_integration(hass) response = await hass.services.async_call( WEATHER_DOMAIN, - SERVICE_GET_FORECAST, + service, { "entity_id": "weather.home", "type": "daily", @@ -111,5 +128,4 @@ async def test_daily_forecast(hass: HomeAssistant, snapshot: SnapshotAssertion) blocking=True, return_response=True, ) - assert response["forecast"] != [] assert response == snapshot diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index f200c44acca..127b45484be 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -18,8 +18,8 @@ from homeassistant.components.websocket_api.auth import ( ) from homeassistant.components.websocket_api.const import FEATURE_COALESCE_MESSAGES, URL from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATIONS -from homeassistant.core import Context, HomeAssistant, State, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import Context, HomeAssistant, State, SupportsResponse, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.loader import async_get_integration @@ -183,14 +183,76 @@ async def test_call_service( assert call.context.as_dict() == msg["result"]["context"] +async def test_return_response_error(hass: HomeAssistant, websocket_client) -> None: + """Test return_response=True errors when service has no response.""" + hass.services.async_register( + "domain_test", "test_service_with_no_response", lambda x: None + ) + await websocket_client.send_json( + { + "id": 8, + "type": "call_service", + "domain": "domain_test", + "service": "test_service_with_no_response", + "service_data": {"hello": "world"}, + "return_response": True, + }, + ) + msg = await websocket_client.receive_json() + + assert msg["id"] == 8 + assert msg["type"] == const.TYPE_RESULT + assert not msg["success"] + assert msg["error"]["code"] == "unknown_error" + + @pytest.mark.parametrize("command", ("call_service", "call_service_action")) async def test_call_service_blocking( hass: HomeAssistant, websocket_client: MockHAClientWebSocket, command ) -> None: """Test call service commands block, except for homeassistant restart / stop.""" + async_mock_service( + hass, + "domain_test", + "test_service", + response={"hello": "world"}, + supports_response=SupportsResponse.OPTIONAL, + ) with patch( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: + mock_call.return_value = {"foo": "bar"} + await websocket_client.send_json( + { + "id": 4, + "type": "call_service", + "domain": "domain_test", + "service": "test_service", + "service_data": {"hello": "world"}, + "return_response": True, + }, + ) + msg = await websocket_client.receive_json() + + assert msg["id"] == 4 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + assert msg["result"]["response"] == {"foo": "bar"} + mock_call.assert_called_once_with( + ANY, + "domain_test", + "test_service", + {"hello": "world"}, + blocking=True, + context=ANY, + target=ANY, + return_response=True, + ) + + with patch( + "homeassistant.core.ServiceRegistry.async_call", autospec=True + ) as mock_call: + mock_call.return_value = None await websocket_client.send_json( { "id": 5, @@ -213,11 +275,14 @@ async def test_call_service_blocking( blocking=True, context=ANY, target=ANY, + return_response=False, ) + async_mock_service(hass, "homeassistant", "test_service") with patch( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: + mock_call.return_value = None await websocket_client.send_json( { "id": 6, @@ -239,11 +304,14 @@ async def test_call_service_blocking( blocking=True, context=ANY, target=ANY, + return_response=False, ) + async_mock_service(hass, "homeassistant", "restart") with patch( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: + mock_call.return_value = None await websocket_client.send_json( { "id": 7, @@ -258,7 +326,14 @@ async def test_call_service_blocking( assert msg["type"] == const.TYPE_RESULT assert msg["success"] mock_call.assert_called_once_with( - ANY, "homeassistant", "restart", ANY, blocking=True, context=ANY, target=ANY + ANY, + "homeassistant", + "restart", + ANY, + blocking=True, + context=ANY, + target=ANY, + return_response=False, ) @@ -343,6 +418,13 @@ async def test_call_service_not_found( assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_NOT_FOUND + assert msg["error"]["message"] == "Service domain_test.test_service not found." + assert msg["error"]["translation_placeholders"] == { + "domain": "domain_test", + "service": "test_service", + } + assert msg["error"]["translation_key"] == "service_not_found" + assert msg["error"]["translation_domain"] == "homeassistant" async def test_call_service_child_not_found( @@ -370,6 +452,18 @@ async def test_call_service_child_not_found( assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_HOME_ASSISTANT_ERROR + assert ( + msg["error"]["message"] == "Service non.existing called service " + "domain_test.test_service which was not found." + ) + assert msg["error"]["translation_placeholders"] == { + "domain": "non", + "service": "existing", + "child_domain": "domain_test", + "child_service": "test_service", + } + assert msg["error"]["translation_key"] == "child_service_not_found" + assert msg["error"]["translation_domain"] == "websocket_api" async def test_call_service_schema_validation_error( @@ -450,10 +544,26 @@ async def test_call_service_error( @callback def ha_error_call(_): - raise HomeAssistantError("error_message") + raise HomeAssistantError( + "error_message", + translation_domain="test", + translation_key="custom_error", + translation_placeholders={"option": "bla"}, + ) hass.services.async_register("domain_test", "ha_error", ha_error_call) + @callback + def service_error_call(_): + raise ServiceValidationError( + "error_message", + translation_domain="test", + translation_key="custom_error", + translation_placeholders={"option": "bla"}, + ) + + hass.services.async_register("domain_test", "service_error", service_error_call) + async def unknown_error_call(_): raise ValueError("value_error") @@ -474,18 +584,40 @@ async def test_call_service_error( assert msg["success"] is False assert msg["error"]["code"] == "home_assistant_error" assert msg["error"]["message"] == "error_message" + assert msg["error"]["translation_placeholders"] == {"option": "bla"} + assert msg["error"]["translation_key"] == "custom_error" + assert msg["error"]["translation_domain"] == "test" await websocket_client.send_json( { "id": 6, "type": "call_service", "domain": "domain_test", + "service": "service_error", + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] is False + assert msg["error"]["code"] == "service_validation_error" + assert msg["error"]["message"] == "Validation error: error_message" + assert msg["error"]["translation_placeholders"] == {"option": "bla"} + assert msg["error"]["translation_key"] == "custom_error" + assert msg["error"]["translation_domain"] == "test" + + await websocket_client.send_json( + { + "id": 7, + "type": "call_service", + "domain": "domain_test", "service": "unknown_error", } ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 + assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] is False assert msg["error"]["code"] == "unknown_error" @@ -2185,6 +2317,65 @@ async def test_execute_script( assert call.context.as_dict() == msg_var["result"]["context"] +@pytest.mark.parametrize( + ("raise_exception", "err_code"), + [ + ( + HomeAssistantError( + "Some error", + translation_domain="test", + translation_key="test_error", + translation_placeholders={"option": "bla"}, + ), + "home_assistant_error", + ), + ( + ServiceValidationError( + "Some error", + translation_domain="test", + translation_key="test_error", + translation_placeholders={"option": "bla"}, + ), + "service_validation_error", + ), + ], +) +async def test_execute_script_err_localization( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + raise_exception: HomeAssistantError, + err_code: str, +) -> None: + """Test testing a condition.""" + async_mock_service( + hass, "domain_test", "test_service", raise_exception=raise_exception + ) + + await websocket_client.send_json( + { + "id": 5, + "type": "execute_script", + "sequence": [ + { + "service": "domain_test.test_service", + "data": {"hello": "world"}, + }, + {"stop": "done", "response_variable": "service_result"}, + ], + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] is False + assert msg["error"]["code"] == err_code + assert msg["error"]["message"] == "Some error" + assert msg["error"]["translation_key"] == "test_error" + assert msg["error"]["translation_domain"] == "test" + assert msg["error"]["translation_placeholders"] == {"option": "bla"} + + async def test_execute_script_complex_response( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: diff --git a/tests/components/websocket_api/test_connection.py b/tests/components/websocket_api/test_connection.py index da435d64d58..80936d30752 100644 --- a/tests/components/websocket_api/test_connection.py +++ b/tests/components/websocket_api/test_connection.py @@ -39,9 +39,15 @@ from tests.common import MockUser ), ( exceptions.HomeAssistantError("Failed to do X"), - websocket_api.ERR_UNKNOWN_ERROR, + websocket_api.ERR_HOME_ASSISTANT_ERROR, "Failed to do X", - "Error handling message: Failed to do X (unknown_error) Mock User from 127.0.0.42 (Browser)", + "Error handling message: Failed to do X (home_assistant_error) Mock User from 127.0.0.42 (Browser)", + ), + ( + exceptions.ServiceValidationError("Failed to do X"), + websocket_api.ERR_HOME_ASSISTANT_ERROR, + "Failed to do X", + "Error handling message: Failed to do X (home_assistant_error) Mock User from 127.0.0.42 (Browser)", ), ( ValueError("Really bad"), diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 59d9b470247..4ca4093e3b8 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -178,6 +178,38 @@ 'state': '1020.121', }) # --- +# name: test_all_entities[sensor.henk_elevation_change_last_workout] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'henk Elevation change last workout', + 'icon': 'mdi:stairs-up', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_elevation_change_last_workout', + 'last_changed': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_all_entities[sensor.henk_elevation_change_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'henk Elevation change today', + 'icon': 'mdi:stairs-up', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_elevation_change_today', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_all_entities[sensor.henk_extracellular_water] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -237,36 +269,6 @@ 'state': '0.07', }) # --- -# name: test_all_entities[sensor.henk_floors_climbed_last_workout] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Floors climbed last workout', - 'icon': 'mdi:stairs-up', - 'unit_of_measurement': 'floors', - }), - 'context': , - 'entity_id': 'sensor.henk_floors_climbed_last_workout', - 'last_changed': , - 'last_updated': , - 'state': '4', - }) -# --- -# name: test_all_entities[sensor.henk_floors_climbed_today] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Floors climbed today', - 'icon': 'mdi:stairs-up', - 'last_reset': '2023-10-20T00:00:00-07:00', - 'state_class': , - 'unit_of_measurement': 'floors', - }), - 'context': , - 'entity_id': 'sensor.henk_floors_climbed_today', - 'last_changed': , - 'last_updated': , - 'state': '0', - }) -# --- # name: test_all_entities[sensor.henk_heart_pulse] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/withings/test_diagnostics.py b/tests/components/withings/test_diagnostics.py index bb5c93e1f09..928eccdde0f 100644 --- a/tests/components/withings/test_diagnostics.py +++ b/tests/components/withings/test_diagnostics.py @@ -67,9 +67,9 @@ async def test_diagnostics_cloudhook_instance( ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.cloud.async_delete_cloudhook" + "homeassistant.components.cloud.async_delete_cloudhook", ), patch( - "homeassistant.components.withings.webhook_generate_url" + "homeassistant.components.withings.webhook_generate_url", ): await setup_integration(hass, webhook_config_entry) await prepare_webhook_setup(hass, freezer) diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 3f20791ac4d..390fbc3bbc3 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -352,7 +352,7 @@ async def test_removing_entry_with_cloud_unavailable( "homeassistant.components.cloud.async_delete_cloudhook", side_effect=CloudNotAvailable(), ), patch( - "homeassistant.components.withings.webhook_generate_url" + "homeassistant.components.withings.webhook_generate_url", ): await setup_integration(hass, cloudhook_config_entry) assert hass.components.cloud.async_active_subscription() is True @@ -469,9 +469,9 @@ async def test_cloud_disconnect( ), patch( "homeassistant.components.withings.async_get_config_entry_implementation", ), patch( - "homeassistant.components.cloud.async_delete_cloudhook" + "homeassistant.components.cloud.async_delete_cloudhook", ), patch( - "homeassistant.components.withings.webhook_generate_url" + "homeassistant.components.withings.webhook_generate_url", ): await setup_integration(hass, webhook_config_entry) await prepare_webhook_setup(hass, freezer) diff --git a/tests/components/workday/__init__.py b/tests/components/workday/__init__.py index f9e44359b00..fb436a57e5c 100644 --- a/tests/components/workday/__init__.py +++ b/tests/components/workday/__init__.py @@ -65,6 +65,17 @@ TEST_CONFIG_WITH_PROVINCE = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "de", +} +TEST_CONFIG_NO_LANGUAGE_CONFIGURED = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], } TEST_CONFIG_INCORRECT_COUNTRY = { "name": DEFAULT_NAME, @@ -74,6 +85,7 @@ TEST_CONFIG_INCORRECT_COUNTRY = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "de", } TEST_CONFIG_INCORRECT_PROVINCE = { "name": DEFAULT_NAME, @@ -84,6 +96,7 @@ TEST_CONFIG_INCORRECT_PROVINCE = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "de", } TEST_CONFIG_NO_PROVINCE = { "name": DEFAULT_NAME, @@ -93,6 +106,7 @@ TEST_CONFIG_NO_PROVINCE = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "de", } TEST_CONFIG_WITH_STATE = { "name": DEFAULT_NAME, @@ -103,6 +117,7 @@ TEST_CONFIG_WITH_STATE = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "en_US", } TEST_CONFIG_NO_STATE = { "name": DEFAULT_NAME, @@ -112,6 +127,7 @@ TEST_CONFIG_NO_STATE = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "en_US", } TEST_CONFIG_INCLUDE_HOLIDAY = { "name": DEFAULT_NAME, @@ -122,6 +138,7 @@ TEST_CONFIG_INCLUDE_HOLIDAY = { "workdays": ["holiday"], "add_holidays": [], "remove_holidays": [], + "language": "de", } TEST_CONFIG_EXAMPLE_1 = { "name": DEFAULT_NAME, @@ -131,6 +148,7 @@ TEST_CONFIG_EXAMPLE_1 = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "en_US", } TEST_CONFIG_EXAMPLE_2 = { "name": DEFAULT_NAME, @@ -141,6 +159,7 @@ TEST_CONFIG_EXAMPLE_2 = { "workdays": ["mon", "wed", "fri"], "add_holidays": ["2020-02-24"], "remove_holidays": [], + "language": "de", } TEST_CONFIG_REMOVE_HOLIDAY = { "name": DEFAULT_NAME, @@ -150,6 +169,7 @@ TEST_CONFIG_REMOVE_HOLIDAY = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": ["2020-12-25", "2020-11-26"], + "language": "en_US", } TEST_CONFIG_REMOVE_NAMED = { "name": DEFAULT_NAME, @@ -159,6 +179,7 @@ TEST_CONFIG_REMOVE_NAMED = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": ["Not a Holiday", "Christmas", "Thanksgiving"], + "language": "en_US", } TEST_CONFIG_TOMORROW = { "name": DEFAULT_NAME, @@ -168,6 +189,7 @@ TEST_CONFIG_TOMORROW = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "de", } TEST_CONFIG_DAY_AFTER_TOMORROW = { "name": DEFAULT_NAME, @@ -177,6 +199,7 @@ TEST_CONFIG_DAY_AFTER_TOMORROW = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "de", } TEST_CONFIG_YESTERDAY = { "name": DEFAULT_NAME, @@ -186,6 +209,7 @@ TEST_CONFIG_YESTERDAY = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": [], + "language": "de", } TEST_CONFIG_INCORRECT_ADD_REMOVE = { "name": DEFAULT_NAME, @@ -196,6 +220,7 @@ TEST_CONFIG_INCORRECT_ADD_REMOVE = { "workdays": DEFAULT_WORKDAYS, "add_holidays": ["2023-12-32"], "remove_holidays": ["2023-12-32"], + "language": "de", } TEST_CONFIG_INCORRECT_ADD_DATE_RANGE = { "name": DEFAULT_NAME, @@ -206,6 +231,7 @@ TEST_CONFIG_INCORRECT_ADD_DATE_RANGE = { "workdays": DEFAULT_WORKDAYS, "add_holidays": ["2023-12-01", "2023-12-30,2023-12-32"], "remove_holidays": [], + "language": "de", } TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE = { "name": DEFAULT_NAME, @@ -216,6 +242,7 @@ TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": ["2023-12-25", "2023-12-30,2023-12-32"], + "language": "de", } TEST_CONFIG_INCORRECT_ADD_DATE_RANGE_LEN = { "name": DEFAULT_NAME, @@ -226,6 +253,7 @@ TEST_CONFIG_INCORRECT_ADD_DATE_RANGE_LEN = { "workdays": DEFAULT_WORKDAYS, "add_holidays": ["2023-12-01", "2023-12-29,2023-12-30,2023-12-31"], "remove_holidays": [], + "language": "de", } TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE_LEN = { "name": DEFAULT_NAME, @@ -236,6 +264,7 @@ TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE_LEN = { "workdays": DEFAULT_WORKDAYS, "add_holidays": [], "remove_holidays": ["2023-12-25", "2023-12-29,2023-12-30,2023-12-31"], + "language": "de", } TEST_CONFIG_ADD_REMOVE_DATE_RANGE = { "name": DEFAULT_NAME, @@ -246,4 +275,27 @@ TEST_CONFIG_ADD_REMOVE_DATE_RANGE = { "workdays": DEFAULT_WORKDAYS, "add_holidays": ["2022-12-01", "2022-12-05,2022-12-15"], "remove_holidays": ["2022-12-04", "2022-12-24,2022-12-26"], + "language": "de", +} +TEST_LANGUAGE_CHANGE = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": ["2022-12-01", "2022-12-05,2022-12-15"], + "remove_holidays": ["2022-12-04", "2022-12-24,2022-12-26"], + "language": "en", +} +TEST_LANGUAGE_NO_CHANGE = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": ["2022-12-01", "2022-12-05,2022-12-15"], + "remove_holidays": ["2022-12-04", "2022-12-24,2022-12-26"], + "language": "de", } diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index eeeb765e4a8..7457d2e0ada 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -1,10 +1,12 @@ """Tests the Home Assistant workday binary sensor.""" -from datetime import datetime +from datetime import date, datetime from typing import Any from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.components.workday.binary_sensor import SERVICE_CHECK_DATE +from homeassistant.components.workday.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.dt import UTC @@ -24,6 +26,7 @@ from . import ( TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE_LEN, TEST_CONFIG_NO_COUNTRY, TEST_CONFIG_NO_COUNTRY_ADD_HOLIDAY, + TEST_CONFIG_NO_LANGUAGE_CONFIGURED, TEST_CONFIG_NO_PROVINCE, TEST_CONFIG_NO_STATE, TEST_CONFIG_REMOVE_HOLIDAY, @@ -32,6 +35,8 @@ from . import ( TEST_CONFIG_WITH_PROVINCE, TEST_CONFIG_WITH_STATE, TEST_CONFIG_YESTERDAY, + TEST_LANGUAGE_CHANGE, + TEST_LANGUAGE_NO_CHANGE, init_integration, ) @@ -49,6 +54,7 @@ from . import ( (TEST_CONFIG_TOMORROW, "off"), (TEST_CONFIG_DAY_AFTER_TOMORROW, "off"), (TEST_CONFIG_YESTERDAY, "on"), + (TEST_CONFIG_NO_LANGUAGE_CONFIGURED, "off"), ], ) async def test_setup( @@ -273,3 +279,57 @@ async def test_setup_date_range( state = hass.states.get("binary_sensor.workday_sensor") assert state.state == "on" + + +async def test_check_date_service( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test check date service with response data.""" + + freezer.move_to(datetime(2017, 1, 6, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_WITH_PROVINCE) + + hass.states.get("binary_sensor.workday_sensor") + + response = await hass.services.async_call( + DOMAIN, + SERVICE_CHECK_DATE, + { + "entity_id": "binary_sensor.workday_sensor", + "check_date": date(2022, 12, 25), # Christmas Day + }, + blocking=True, + return_response=True, + ) + assert response == {"binary_sensor.workday_sensor": {"workday": False}} + + response = await hass.services.async_call( + DOMAIN, + SERVICE_CHECK_DATE, + { + "entity_id": "binary_sensor.workday_sensor", + "check_date": date(2022, 12, 23), # Normal Friday + }, + blocking=True, + return_response=True, + ) + assert response == {"binary_sensor.workday_sensor": {"workday": True}} + + +async def test_language_difference_english_language( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling difference in English language naming.""" + await init_integration(hass, TEST_LANGUAGE_CHANGE) + assert "Changing language from en to en_US" in caplog.text + + +async def test_language_difference_no_change_other_language( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test skipping if no difference in language naming.""" + await init_integration(hass, TEST_LANGUAGE_NO_CHANGE) + assert "Changing language from en to en_US" not in caplog.text diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index 89a001e0b55..57a7046546e 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -1,6 +1,9 @@ """Test the Workday config flow.""" from __future__ import annotations +from datetime import datetime + +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import config_entries @@ -16,9 +19,10 @@ from homeassistant.components.workday.const import ( DEFAULT_WORKDAYS, DOMAIN, ) -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_LANGUAGE, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.util.dt import UTC from . import init_integration @@ -49,6 +53,7 @@ async def test_form(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: [], CONF_REMOVE_HOLIDAYS: [], + CONF_LANGUAGE: "de", }, ) await hass.async_block_till_done() @@ -63,6 +68,7 @@ async def test_form(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], + "language": "de", } @@ -143,6 +149,7 @@ async def test_form_no_subdivision(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], + "language": "sv", } @@ -159,6 +166,7 @@ async def test_options_form(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], + "language": "de", }, ) @@ -173,6 +181,7 @@ async def test_options_form(hass: HomeAssistant) -> None: "add_holidays": [], "remove_holidays": [], "province": "BW", + "language": "de", }, ) @@ -186,6 +195,7 @@ async def test_options_form(hass: HomeAssistant) -> None: "add_holidays": [], "remove_holidays": [], "province": "BW", + "language": "de", } @@ -213,6 +223,7 @@ async def test_form_incorrect_dates(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: ["2022-xx-12"], CONF_REMOVE_HOLIDAYS: [], + CONF_LANGUAGE: "de", }, ) await hass.async_block_till_done() @@ -226,6 +237,7 @@ async def test_form_incorrect_dates(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: ["2022-12-12"], CONF_REMOVE_HOLIDAYS: ["Does not exist"], + CONF_LANGUAGE: "de", }, ) await hass.async_block_till_done() @@ -240,6 +252,7 @@ async def test_form_incorrect_dates(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: ["2022-12-12"], CONF_REMOVE_HOLIDAYS: ["Weihnachtstag"], + CONF_LANGUAGE: "de", }, ) await hass.async_block_till_done() @@ -254,6 +267,7 @@ async def test_form_incorrect_dates(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": ["2022-12-12"], "remove_holidays": ["Weihnachtstag"], + "language": "de", } @@ -270,6 +284,7 @@ async def test_options_form_incorrect_dates(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], + "language": "de", }, ) @@ -284,6 +299,7 @@ async def test_options_form_incorrect_dates(hass: HomeAssistant) -> None: "add_holidays": ["2022-xx-12"], "remove_holidays": [], "province": "BW", + "language": "de", }, ) @@ -298,6 +314,7 @@ async def test_options_form_incorrect_dates(hass: HomeAssistant) -> None: "add_holidays": ["2022-12-12"], "remove_holidays": ["Does not exist"], "province": "BW", + "language": "de", }, ) @@ -312,6 +329,7 @@ async def test_options_form_incorrect_dates(hass: HomeAssistant) -> None: "add_holidays": ["2022-12-12"], "remove_holidays": ["Weihnachtstag"], "province": "BW", + "language": "de", }, ) @@ -325,6 +343,7 @@ async def test_options_form_incorrect_dates(hass: HomeAssistant) -> None: "add_holidays": ["2022-12-12"], "remove_holidays": ["Weihnachtstag"], "province": "BW", + "language": "de", } @@ -401,6 +420,7 @@ async def test_form_incorrect_date_range(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: ["2022-12-12", "2022-12-30,2022-12-32"], CONF_REMOVE_HOLIDAYS: [], + CONF_LANGUAGE: "de", }, ) await hass.async_block_till_done() @@ -414,6 +434,7 @@ async def test_form_incorrect_date_range(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: ["2022-12-12"], CONF_REMOVE_HOLIDAYS: ["2022-12-25", "2022-12-30,2022-12-32"], + CONF_LANGUAGE: "de", }, ) await hass.async_block_till_done() @@ -428,6 +449,7 @@ async def test_form_incorrect_date_range(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: ["2022-12-12", "2022-12-01,2022-12-10"], CONF_REMOVE_HOLIDAYS: ["2022-12-25", "2022-12-30,2022-12-31"], + CONF_LANGUAGE: "de", }, ) await hass.async_block_till_done() @@ -442,6 +464,7 @@ async def test_form_incorrect_date_range(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": ["2022-12-12", "2022-12-01,2022-12-10"], "remove_holidays": ["2022-12-25", "2022-12-30,2022-12-31"], + "language": "de", } @@ -458,6 +481,7 @@ async def test_options_form_incorrect_date_ranges(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], + "language": "de", }, ) @@ -472,6 +496,7 @@ async def test_options_form_incorrect_date_ranges(hass: HomeAssistant) -> None: "add_holidays": ["2022-12-30,2022-12-32"], "remove_holidays": [], "province": "BW", + "language": "de", }, ) @@ -486,6 +511,7 @@ async def test_options_form_incorrect_date_ranges(hass: HomeAssistant) -> None: "add_holidays": ["2022-12-30,2022-12-31"], "remove_holidays": ["2022-13-25,2022-12-26"], "province": "BW", + "language": "de", }, ) @@ -500,6 +526,7 @@ async def test_options_form_incorrect_date_ranges(hass: HomeAssistant) -> None: "add_holidays": ["2022-12-30,2022-12-31"], "remove_holidays": ["2022-12-25,2022-12-26"], "province": "BW", + "language": "de", }, ) @@ -513,4 +540,65 @@ async def test_options_form_incorrect_date_ranges(hass: HomeAssistant) -> None: "add_holidays": ["2022-12-30,2022-12-31"], "remove_holidays": ["2022-12-25,2022-12-26"], "province": "BW", + "language": "de", } + + +pytestmark = pytest.mark.usefixtures() + + +@pytest.mark.parametrize( + ("language", "holiday"), + [ + ("de", "Weihnachtstag"), + ("en", "Christmas"), + ], +) +async def test_language( + hass: HomeAssistant, language: str, holiday: str, freezer: FrozenDateTimeFactory +) -> None: + """Test we get the forms.""" + freezer.move_to(datetime(2023, 12, 25, 12, tzinfo=UTC)) # Monday + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "Workday Sensor", + CONF_COUNTRY: "DE", + }, + ) + await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: [], + CONF_REMOVE_HOLIDAYS: [holiday], + CONF_LANGUAGE: language, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "Workday Sensor" + assert result3["options"] == { + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [holiday], + "language": language, + } + + state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None + assert state.state == "on" diff --git a/tests/components/wyoming/__init__.py b/tests/components/wyoming/__init__.py index e04ff4eda03..899eda7ec1a 100644 --- a/tests/components/wyoming/__init__.py +++ b/tests/components/wyoming/__init__.py @@ -1,11 +1,13 @@ """Tests for the Wyoming integration.""" import asyncio +from wyoming.event import Event from wyoming.info import ( AsrModel, AsrProgram, Attribution, Info, + Satellite, TtsProgram, TtsVoice, TtsVoiceSpeaker, @@ -72,24 +74,36 @@ WAKE_WORD_INFO = Info( ) ] ) +SATELLITE_INFO = Info( + satellite=Satellite( + name="Test Satellite", + description="Test Satellite", + installed=True, + attribution=TEST_ATTR, + area="Office", + ) +) EMPTY_INFO = Info() class MockAsyncTcpClient: """Mock AsyncTcpClient.""" - def __init__(self, responses) -> None: + def __init__(self, responses: list[Event]) -> None: """Initialize.""" - self.host = None - self.port = None - self.written = [] + self.host: str | None = None + self.port: int | None = None + self.written: list[Event] = [] self.responses = responses - async def write_event(self, event): + async def connect(self) -> None: + """Connect.""" + + async def write_event(self, event: Event): """Send.""" self.written.append(event) - async def read_event(self): + async def read_event(self) -> Event | None: """Receive.""" await asyncio.sleep(0) # force context switch @@ -105,7 +119,7 @@ class MockAsyncTcpClient: async def __aexit__(self, exc_type, exc, tb): """Exit.""" - def __call__(self, host, port): + def __call__(self, host: str, port: int): """Call.""" self.host = host self.port = port diff --git a/tests/components/wyoming/conftest.py b/tests/components/wyoming/conftest.py index 2c8081908f7..a30c1048eb6 100644 --- a/tests/components/wyoming/conftest.py +++ b/tests/components/wyoming/conftest.py @@ -5,14 +5,23 @@ from unittest.mock import AsyncMock, patch import pytest from homeassistant.components import stt +from homeassistant.components.wyoming import DOMAIN +from homeassistant.components.wyoming.devices import SatelliteDevice from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component -from . import STT_INFO, TTS_INFO, WAKE_WORD_INFO +from . import SATELLITE_INFO, STT_INFO, TTS_INFO, WAKE_WORD_INFO from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +async def init_components(hass: HomeAssistant): + """Set up required components.""" + assert await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: """Override async_setup_entry.""" @@ -110,3 +119,39 @@ def metadata(hass: HomeAssistant) -> stt.SpeechMetadata: sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, channel=stt.AudioChannels.CHANNEL_MONO, ) + + +@pytest.fixture +def satellite_config_entry(hass: HomeAssistant) -> ConfigEntry: + """Create a config entry.""" + entry = MockConfigEntry( + domain="wyoming", + data={ + "host": "1.2.3.4", + "port": 1234, + }, + title="Test Satellite", + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +async def init_satellite(hass: HomeAssistant, satellite_config_entry: ConfigEntry): + """Initialize Wyoming satellite.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.run" + ) as _run_mock: + # _run_mock: satellite task does not actually run + await hass.config_entries.async_setup(satellite_config_entry.entry_id) + + +@pytest.fixture +async def satellite_device( + hass: HomeAssistant, init_satellite, satellite_config_entry: ConfigEntry +) -> SatelliteDevice: + """Get a satellite device fixture.""" + return hass.data[DOMAIN][satellite_config_entry.entry_id].satellite.device diff --git a/tests/components/wyoming/snapshots/test_config_flow.ambr b/tests/components/wyoming/snapshots/test_config_flow.ambr index d4220a39724..99f411027f5 100644 --- a/tests/components/wyoming/snapshots/test_config_flow.ambr +++ b/tests/components/wyoming/snapshots/test_config_flow.ambr @@ -121,3 +121,45 @@ 'version': 1, }) # --- +# name: test_zeroconf_discovery + FlowResultSnapshot({ + 'context': dict({ + 'name': 'Test Satellite', + 'source': 'zeroconf', + 'title_placeholders': dict({ + 'name': 'Test Satellite', + }), + 'unique_id': 'test_zeroconf_name._wyoming._tcp.local._Test Satellite', + }), + 'data': dict({ + 'host': '127.0.0.1', + 'port': 12345, + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'wyoming', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'host': '127.0.0.1', + 'port': 12345, + }), + 'disabled_by': None, + 'domain': 'wyoming', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'zeroconf', + 'title': 'Test Satellite', + 'unique_id': 'test_zeroconf_name._wyoming._tcp.local._Test Satellite', + 'version': 1, + }), + 'title': 'Test Satellite', + 'type': , + 'version': 1, + }) +# --- diff --git a/tests/components/wyoming/snapshots/test_tts.ambr b/tests/components/wyoming/snapshots/test_tts.ambr index 1cb5a6cb874..299bddb07e5 100644 --- a/tests/components/wyoming/snapshots/test_tts.ambr +++ b/tests/components/wyoming/snapshots/test_tts.ambr @@ -10,6 +10,39 @@ }), ]) # --- +# name: test_get_tts_audio_different_formats + list([ + dict({ + 'data': dict({ + 'text': 'Hello world', + }), + 'payload': None, + 'type': 'synthesize', + }), + ]) +# --- +# name: test_get_tts_audio_different_formats.1 + list([ + dict({ + 'data': dict({ + 'text': 'Hello world', + }), + 'payload': None, + 'type': 'synthesize', + }), + ]) +# --- +# name: test_get_tts_audio_mp3 + list([ + dict({ + 'data': dict({ + 'text': 'Hello world', + }), + 'payload': None, + 'type': 'synthesize', + }), + ]) +# --- # name: test_get_tts_audio_raw list([ dict({ diff --git a/tests/components/wyoming/test_binary_sensor.py b/tests/components/wyoming/test_binary_sensor.py new file mode 100644 index 00000000000..27294186a90 --- /dev/null +++ b/tests/components/wyoming/test_binary_sensor.py @@ -0,0 +1,34 @@ +"""Test Wyoming binary sensor devices.""" +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + + +async def test_assist_in_progress( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test assist in progress.""" + assist_in_progress_id = satellite_device.get_assist_in_progress_entity_id(hass) + assert assist_in_progress_id + + state = hass.states.get(assist_in_progress_id) + assert state is not None + assert state.state == STATE_OFF + assert not satellite_device.is_active + + satellite_device.set_is_active(True) + + state = hass.states.get(assist_in_progress_id) + assert state is not None + assert state.state == STATE_ON + assert satellite_device.is_active + + satellite_device.set_is_active(False) + + state = hass.states.get(assist_in_progress_id) + assert state is not None + assert state.state == STATE_OFF + assert not satellite_device.is_active diff --git a/tests/components/wyoming/test_config_flow.py b/tests/components/wyoming/test_config_flow.py index 896d3748ebd..f711b56b3bc 100644 --- a/tests/components/wyoming/test_config_flow.py +++ b/tests/components/wyoming/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Wyoming config flow.""" +from ipaddress import IPv4Address from unittest.mock import AsyncMock, patch import pytest @@ -8,10 +9,11 @@ from wyoming.info import Info from homeassistant import config_entries from homeassistant.components.hassio import HassioServiceInfo from homeassistant.components.wyoming.const import DOMAIN +from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import EMPTY_INFO, STT_INFO, TTS_INFO +from . import EMPTY_INFO, SATELLITE_INFO, STT_INFO, TTS_INFO from tests.common import MockConfigEntry @@ -25,6 +27,16 @@ ADDON_DISCOVERY = HassioServiceInfo( uuid="1234", ) +ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=IPv4Address("127.0.0.1"), + ip_addresses=[IPv4Address("127.0.0.1")], + port=12345, + hostname="localhost", + type="_wyoming._tcp.local.", + name="test_zeroconf_name._wyoming._tcp.local.", + properties={}, +) + pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -214,3 +226,70 @@ async def test_hassio_addon_no_supported_services(hass: HomeAssistant) -> None: assert result2.get("type") == FlowResultType.ABORT assert result2.get("reason") == "no_services" + + +async def test_zeroconf_discovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config flow initiated by Supervisor.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ZEROCONF_DISCOVERY, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "zeroconf_confirm" + assert result.get("description_placeholders") == { + "name": SATELLITE_INFO.satellite.name + } + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result2.get("type") == FlowResultType.CREATE_ENTRY + assert result2 == snapshot + + +async def test_zeroconf_discovery_no_port( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test discovery when the zeroconf service does not have a port.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch.object(ZEROCONF_DISCOVERY, "port", None): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ZEROCONF_DISCOVERY, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "no_port" + + +async def test_zeroconf_discovery_no_services( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test discovery when there are no supported services on the client.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=Info(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ZEROCONF_DISCOVERY, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "no_services" diff --git a/tests/components/wyoming/test_data.py b/tests/components/wyoming/test_data.py index 0cb878c39c1..b7de9dbfdc1 100644 --- a/tests/components/wyoming/test_data.py +++ b/tests/components/wyoming/test_data.py @@ -3,13 +3,15 @@ from __future__ import annotations from unittest.mock import patch -from homeassistant.components.wyoming.data import load_wyoming_info +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.wyoming.data import WyomingService, load_wyoming_info from homeassistant.core import HomeAssistant -from . import STT_INFO, MockAsyncTcpClient +from . import SATELLITE_INFO, STT_INFO, TTS_INFO, WAKE_WORD_INFO, MockAsyncTcpClient -async def test_load_info(hass: HomeAssistant, snapshot) -> None: +async def test_load_info(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Test loading info.""" with patch( "homeassistant.components.wyoming.data.AsyncTcpClient", @@ -38,3 +40,38 @@ async def test_load_info_oserror(hass: HomeAssistant) -> None: ) assert info is None + + +async def test_service_name(hass: HomeAssistant) -> None: + """Test loading service info.""" + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([STT_INFO.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == STT_INFO.asr[0].name + + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([TTS_INFO.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == TTS_INFO.tts[0].name + + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([WAKE_WORD_INFO.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == WAKE_WORD_INFO.wake[0].name + + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([SATELLITE_INFO.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == SATELLITE_INFO.satellite.name diff --git a/tests/components/wyoming/test_devices.py b/tests/components/wyoming/test_devices.py new file mode 100644 index 00000000000..549f76f20f1 --- /dev/null +++ b/tests/components/wyoming/test_devices.py @@ -0,0 +1,78 @@ +"""Test Wyoming devices.""" +from __future__ import annotations + +from homeassistant.components.assist_pipeline.select import OPTION_PREFERRED +from homeassistant.components.wyoming import DOMAIN +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + + +async def test_device_registry_info( + hass: HomeAssistant, + satellite_device: SatelliteDevice, + satellite_config_entry: ConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test info in device registry.""" + + # Satellite uses config entry id since only one satellite per entry is + # supported. + device = device_registry.async_get_device( + identifiers={(DOMAIN, satellite_config_entry.entry_id)} + ) + assert device is not None + assert device.name == "Test Satellite" + assert device.suggested_area == "Office" + + # Check associated entities + assist_in_progress_id = satellite_device.get_assist_in_progress_entity_id(hass) + assert assist_in_progress_id + assist_in_progress_state = hass.states.get(assist_in_progress_id) + assert assist_in_progress_state is not None + assert assist_in_progress_state.state == STATE_OFF + + satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass) + assert satellite_enabled_id + satellite_enabled_state = hass.states.get(satellite_enabled_id) + assert satellite_enabled_state is not None + assert satellite_enabled_state.state == STATE_ON + + pipeline_entity_id = satellite_device.get_pipeline_entity_id(hass) + assert pipeline_entity_id + pipeline_state = hass.states.get(pipeline_entity_id) + assert pipeline_state is not None + assert pipeline_state.state == OPTION_PREFERRED + + +async def test_remove_device_registry_entry( + hass: HomeAssistant, + satellite_device: SatelliteDevice, + device_registry: dr.DeviceRegistry, +) -> None: + """Test removing a device registry entry.""" + + # Check associated entities + assist_in_progress_id = satellite_device.get_assist_in_progress_entity_id(hass) + assert assist_in_progress_id + assert hass.states.get(assist_in_progress_id) is not None + + satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass) + assert satellite_enabled_id + assert hass.states.get(satellite_enabled_id) is not None + + pipeline_entity_id = satellite_device.get_pipeline_entity_id(hass) + assert pipeline_entity_id + assert hass.states.get(pipeline_entity_id) is not None + + # Remove + device_registry.async_remove_device(satellite_device.device_id) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Everything should be gone + assert hass.states.get(assist_in_progress_id) is None + assert hass.states.get(satellite_enabled_id) is None + assert hass.states.get(pipeline_entity_id) is None diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py new file mode 100644 index 00000000000..06ae337a19c --- /dev/null +++ b/tests/components/wyoming/test_satellite.py @@ -0,0 +1,460 @@ +"""Test Wyoming satellite.""" +from __future__ import annotations + +import asyncio +import io +from unittest.mock import patch +import wave + +from wyoming.asr import Transcribe, Transcript +from wyoming.audio import AudioChunk, AudioStart, AudioStop +from wyoming.event import Event +from wyoming.pipeline import PipelineStage, RunPipeline +from wyoming.satellite import RunSatellite +from wyoming.tts import Synthesize +from wyoming.vad import VoiceStarted, VoiceStopped +from wyoming.wake import Detect, Detection + +from homeassistant.components import assist_pipeline, wyoming +from homeassistant.components.wyoming.data import WyomingService +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import SATELLITE_INFO, MockAsyncTcpClient + +from tests.common import MockConfigEntry + + +async def setup_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Set up config entry for Wyoming satellite. + + This is separated from the satellite_config_entry method in conftest.py so + we can patch functions before the satellite task is run during setup. + """ + entry = MockConfigEntry( + domain="wyoming", + data={ + "host": "1.2.3.4", + "port": 1234, + }, + title="Test Satellite", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + +def get_test_wav() -> bytes: + """Get bytes for test WAV file.""" + with io.BytesIO() as wav_io: + with wave.open(wav_io, "wb") as wav_file: + wav_file.setframerate(22050) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + + # Single frame + wav_file.writeframes(b"123") + + return wav_io.getvalue() + + +class SatelliteAsyncTcpClient(MockAsyncTcpClient): + """Satellite AsyncTcpClient.""" + + def __init__(self, responses: list[Event]) -> None: + """Initialize client.""" + super().__init__(responses) + + self.connect_event = asyncio.Event() + self.run_satellite_event = asyncio.Event() + self.detect_event = asyncio.Event() + + self.detection_event = asyncio.Event() + self.detection: Detection | None = None + + self.transcribe_event = asyncio.Event() + self.transcribe: Transcribe | None = None + + self.voice_started_event = asyncio.Event() + self.voice_started: VoiceStarted | None = None + + self.voice_stopped_event = asyncio.Event() + self.voice_stopped: VoiceStopped | None = None + + self.transcript_event = asyncio.Event() + self.transcript: Transcript | None = None + + self.synthesize_event = asyncio.Event() + self.synthesize: Synthesize | None = None + + self.tts_audio_start_event = asyncio.Event() + self.tts_audio_chunk_event = asyncio.Event() + self.tts_audio_stop_event = asyncio.Event() + self.tts_audio_chunk: AudioChunk | None = None + + self._mic_audio_chunk = AudioChunk( + rate=16000, width=2, channels=1, audio=b"chunk" + ).event() + + async def connect(self) -> None: + """Connect.""" + self.connect_event.set() + + async def write_event(self, event: Event): + """Send.""" + if RunSatellite.is_type(event.type): + self.run_satellite_event.set() + elif Detect.is_type(event.type): + self.detect_event.set() + elif Detection.is_type(event.type): + self.detection = Detection.from_event(event) + self.detection_event.set() + elif Transcribe.is_type(event.type): + self.transcribe = Transcribe.from_event(event) + self.transcribe_event.set() + elif VoiceStarted.is_type(event.type): + self.voice_started = VoiceStarted.from_event(event) + self.voice_started_event.set() + elif VoiceStopped.is_type(event.type): + self.voice_stopped = VoiceStopped.from_event(event) + self.voice_stopped_event.set() + elif Transcript.is_type(event.type): + self.transcript = Transcript.from_event(event) + self.transcript_event.set() + elif Synthesize.is_type(event.type): + self.synthesize = Synthesize.from_event(event) + self.synthesize_event.set() + elif AudioStart.is_type(event.type): + self.tts_audio_start_event.set() + elif AudioChunk.is_type(event.type): + self.tts_audio_chunk = AudioChunk.from_event(event) + self.tts_audio_chunk_event.set() + elif AudioStop.is_type(event.type): + self.tts_audio_stop_event.set() + + async def read_event(self) -> Event | None: + """Receive.""" + event = await super().read_event() + + # Keep sending audio chunks instead of None + return event or self._mic_audio_chunk + + +async def test_satellite_pipeline(hass: HomeAssistant) -> None: + """Test running a pipeline with a satellite.""" + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(events), + ) as mock_client, patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + ) as mock_run_pipeline, patch( + "homeassistant.components.wyoming.satellite.tts.async_get_media_source_audio", + return_value=("wav", get_test_wav()), + ): + entry = await setup_config_entry(hass) + device: SatelliteDevice = hass.data[wyoming.DOMAIN][ + entry.entry_id + ].satellite.device + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + mock_run_pipeline.assert_called() + event_callback = mock_run_pipeline.call_args.kwargs["event_callback"] + + # Start detecting wake word + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.WAKE_WORD_START + ) + ) + async with asyncio.timeout(1): + await mock_client.detect_event.wait() + + assert not device.is_active + assert device.is_enabled + + # Wake word is detected + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.WAKE_WORD_END, + {"wake_word_output": {"wake_word_id": "test_wake_word"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.detection_event.wait() + + assert mock_client.detection is not None + assert mock_client.detection.name == "test_wake_word" + + # "Assist in progress" sensor should be active now + assert device.is_active + + # Speech-to-text started + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_START, + {"metadata": {"language": "en"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.transcribe_event.wait() + + assert mock_client.transcribe is not None + assert mock_client.transcribe.language == "en" + + # User started speaking + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_VAD_START, {"timestamp": 1234} + ) + ) + async with asyncio.timeout(1): + await mock_client.voice_started_event.wait() + + assert mock_client.voice_started is not None + assert mock_client.voice_started.timestamp == 1234 + + # User stopped speaking + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_VAD_END, {"timestamp": 5678} + ) + ) + async with asyncio.timeout(1): + await mock_client.voice_stopped_event.wait() + + assert mock_client.voice_stopped is not None + assert mock_client.voice_stopped.timestamp == 5678 + + # Speech-to-text transcription + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_END, + {"stt_output": {"text": "test transcript"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.transcript_event.wait() + + assert mock_client.transcript is not None + assert mock_client.transcript.text == "test transcript" + + # Text-to-speech text + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_START, + { + "tts_input": "test text to speak", + "voice": "test voice", + }, + ) + ) + async with asyncio.timeout(1): + await mock_client.synthesize_event.wait() + + assert mock_client.synthesize is not None + assert mock_client.synthesize.text == "test text to speak" + assert mock_client.synthesize.voice is not None + assert mock_client.synthesize.voice.name == "test voice" + + # Text-to-speech media + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_END, + {"tts_output": {"media_id": "test media id"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.tts_audio_start_event.wait() + await mock_client.tts_audio_chunk_event.wait() + await mock_client.tts_audio_stop_event.wait() + + # Verify audio chunk from test WAV + assert mock_client.tts_audio_chunk is not None + assert mock_client.tts_audio_chunk.rate == 22050 + assert mock_client.tts_audio_chunk.width == 2 + assert mock_client.tts_audio_chunk.channels == 1 + assert mock_client.tts_audio_chunk.audio == b"123" + + # Pipeline finished + event_callback( + assist_pipeline.PipelineEvent(assist_pipeline.PipelineEventType.RUN_END) + ) + assert not device.is_active + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_satellite_disabled(hass: HomeAssistant) -> None: + """Test callback for a satellite that has been disabled.""" + on_disabled_event = asyncio.Event() + + original_make_satellite = wyoming._make_satellite + + def make_disabled_satellite( + hass: HomeAssistant, config_entry: ConfigEntry, service: WyomingService + ): + satellite = original_make_satellite(hass, config_entry, service) + satellite.device.is_enabled = False + + return satellite + + async def on_disabled(self): + on_disabled_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming._make_satellite", make_disabled_satellite + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_disabled", + on_disabled, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_disabled_event.wait() + + +async def test_satellite_restart(hass: HomeAssistant) -> None: + """Test pipeline loop restart after unexpected error.""" + on_restart_event = asyncio.Event() + + async def on_restart(self): + self.stop() + on_restart_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite._run_once", + side_effect=RuntimeError(), + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_restart", + on_restart, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_restart_event.wait() + + +async def test_satellite_reconnect(hass: HomeAssistant) -> None: + """Test satellite reconnect call after connection refused.""" + on_reconnect_event = asyncio.Event() + + async def on_reconnect(self): + self.stop() + on_reconnect_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient.connect", + side_effect=ConnectionRefusedError(), + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_reconnect", + on_reconnect, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_reconnect_event.wait() + + +async def test_satellite_disconnect_before_pipeline(hass: HomeAssistant) -> None: + """Test satellite disconnecting before pipeline run.""" + on_restart_event = asyncio.Event() + + async def on_restart(self): + self.stop() + on_restart_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + MockAsyncTcpClient([]), # no RunPipeline event + ), patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + ) as mock_run_pipeline, patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_restart", + on_restart, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_restart_event.wait() + + # Pipeline should never have run + mock_run_pipeline.assert_not_called() + + +async def test_satellite_disconnect_during_pipeline(hass: HomeAssistant) -> None: + """Test satellite disconnecting during pipeline run.""" + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] # no audio chunks after RunPipeline + + on_restart_event = asyncio.Event() + on_stopped_event = asyncio.Event() + + async def on_restart(self): + # Pretend sensor got stuck on + self.device.is_active = True + self.stop() + on_restart_event.set() + + async def on_stopped(self): + on_stopped_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + MockAsyncTcpClient(events), + ), patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + ) as mock_run_pipeline, patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_restart", + on_restart, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_stopped", + on_stopped, + ): + entry = await setup_config_entry(hass) + device: SatelliteDevice = hass.data[wyoming.DOMAIN][ + entry.entry_id + ].satellite.device + + async with asyncio.timeout(1): + await on_restart_event.wait() + await on_stopped_event.wait() + + # Pipeline should have run once + mock_run_pipeline.assert_called_once() + + # Sensor should have been turned off + assert not device.is_active diff --git a/tests/components/wyoming/test_select.py b/tests/components/wyoming/test_select.py new file mode 100644 index 00000000000..cab699336fb --- /dev/null +++ b/tests/components/wyoming/test_select.py @@ -0,0 +1,83 @@ +"""Test Wyoming select.""" +from unittest.mock import Mock, patch + +from homeassistant.components import assist_pipeline +from homeassistant.components.assist_pipeline.pipeline import PipelineData +from homeassistant.components.assist_pipeline.select import OPTION_PREFERRED +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +async def test_pipeline_select( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test pipeline select. + + Functionality is tested in assist_pipeline/test_select.py. + This test is only to ensure it is set up. + """ + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + pipeline_data: PipelineData = hass.data[assist_pipeline.DOMAIN] + + # Create second pipeline + await pipeline_data.pipeline_store.async_create_item( + { + "name": "Test 1", + "language": "en-US", + "conversation_engine": None, + "conversation_language": "en-US", + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + "stt_engine": None, + "stt_language": None, + "wake_word_entity": None, + "wake_word_id": None, + } + ) + + # Preferred pipeline is the default + pipeline_entity_id = satellite_device.get_pipeline_entity_id(hass) + assert pipeline_entity_id + + state = hass.states.get(pipeline_entity_id) + assert state is not None + assert state.state == OPTION_PREFERRED + + # Change to second pipeline + with patch.object(satellite_device, "set_pipeline_name") as mock_pipeline_changed: + await hass.services.async_call( + "select", + "select_option", + {"entity_id": pipeline_entity_id, "option": "Test 1"}, + blocking=True, + ) + + state = hass.states.get(pipeline_entity_id) + assert state is not None + assert state.state == "Test 1" + + # async_pipeline_changed should have been called + mock_pipeline_changed.assert_called_once_with("Test 1") + + # Change back and check update listener + pipeline_listener = Mock() + satellite_device.set_pipeline_listener(pipeline_listener) + + await hass.services.async_call( + "select", + "select_option", + {"entity_id": pipeline_entity_id, "option": OPTION_PREFERRED}, + blocking=True, + ) + + state = hass.states.get(pipeline_entity_id) + assert state is not None + assert state.state == OPTION_PREFERRED + + # listener should have been called + pipeline_listener.assert_called_once() diff --git a/tests/components/wyoming/test_switch.py b/tests/components/wyoming/test_switch.py new file mode 100644 index 00000000000..0b05724d761 --- /dev/null +++ b/tests/components/wyoming/test_switch.py @@ -0,0 +1,32 @@ +"""Test Wyoming switch devices.""" +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + + +async def test_satellite_enabled( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test satellite enabled.""" + satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass) + assert satellite_enabled_id + + state = hass.states.get(satellite_enabled_id) + assert state is not None + assert state.state == STATE_ON + assert satellite_device.is_enabled + + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": satellite_enabled_id}, + blocking=True, + ) + + state = hass.states.get(satellite_enabled_id) + assert state is not None + assert state.state == STATE_OFF + assert not satellite_device.is_enabled diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index 51a684bc4fd..2f2a25558e4 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -51,31 +51,7 @@ async def test_get_tts_audio(hass: HomeAssistant, init_wyoming_tts, snapshot) -> AudioStop().event(), ] - with patch( - "homeassistant.components.wyoming.tts.AsyncTcpClient", - MockAsyncTcpClient(audio_events), - ) as mock_client: - extension, data = await tts.async_get_media_source_audio( - hass, - tts.generate_media_source_id(hass, "Hello world", "tts.test_tts", "en-US"), - ) - - assert extension == "wav" - assert data is not None - with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: - assert wav_file.getframerate() == 16000 - assert wav_file.getsampwidth() == 2 - assert wav_file.getnchannels() == 1 - assert wav_file.readframes(wav_file.getnframes()) == audio - - assert mock_client.written == snapshot - - -async def test_get_tts_audio_raw( - hass: HomeAssistant, init_wyoming_tts, snapshot -) -> None: - """Test get raw audio.""" - audio = bytes(100) + # Verify audio audio_events = [ AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), AudioStop().event(), @@ -92,12 +68,83 @@ async def test_get_tts_audio_raw( "Hello world", "tts.test_tts", "en-US", - options={tts.ATTR_AUDIO_OUTPUT: "raw"}, + options={tts.ATTR_PREFERRED_FORMAT: "wav"}, ), ) - assert extension == "raw" - assert data == audio + assert extension == "wav" + assert data is not None + with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: + assert wav_file.getframerate() == 16000 + assert wav_file.getsampwidth() == 2 + assert wav_file.getnchannels() == 1 + assert wav_file.readframes(wav_file.getnframes()) == audio + + assert mock_client.written == snapshot + + +async def test_get_tts_audio_different_formats( + hass: HomeAssistant, init_wyoming_tts, snapshot +) -> None: + """Test changing preferred audio format.""" + audio = bytes(16000 * 2 * 1) # one second + audio_events = [ + AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), + AudioStop().event(), + ] + + # Request a different sample rate, etc. + with patch( + "homeassistant.components.wyoming.tts.AsyncTcpClient", + MockAsyncTcpClient(audio_events), + ) as mock_client: + extension, data = await tts.async_get_media_source_audio( + hass, + tts.generate_media_source_id( + hass, + "Hello world", + "tts.test_tts", + "en-US", + options={ + tts.ATTR_PREFERRED_FORMAT: "wav", + tts.ATTR_PREFERRED_SAMPLE_RATE: 48000, + tts.ATTR_PREFERRED_SAMPLE_CHANNELS: 2, + }, + ), + ) + + assert extension == "wav" + assert data is not None + with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: + assert wav_file.getframerate() == 48000 + assert wav_file.getsampwidth() == 2 + assert wav_file.getnchannels() == 2 + assert wav_file.getnframes() == wav_file.getframerate() # one second + + assert mock_client.written == snapshot + + # MP3 is the default + audio_events = [ + AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), + AudioStop().event(), + ] + + with patch( + "homeassistant.components.wyoming.tts.AsyncTcpClient", + MockAsyncTcpClient(audio_events), + ) as mock_client: + extension, data = await tts.async_get_media_source_audio( + hass, + tts.generate_media_source_id( + hass, + "Hello world", + "tts.test_tts", + "en-US", + ), + ) + + assert extension == "mp3" + assert b"ID3" in data assert mock_client.written == snapshot @@ -133,7 +180,7 @@ async def test_get_tts_audio_audio_oserror( ), patch.object( mock_client, "read_event", side_effect=OSError("Boom!") ), pytest.raises( - HomeAssistantError + HomeAssistantError, ): await tts.async_get_media_source_audio( hass, diff --git a/tests/components/yamaha_musiccast/test_config_flow.py b/tests/components/yamaha_musiccast/test_config_flow.py index ccccd98b3b6..4ce95e418d0 100644 --- a/tests/components/yamaha_musiccast/test_config_flow.py +++ b/tests/components/yamaha_musiccast/test_config_flow.py @@ -22,9 +22,9 @@ async def silent_ssdp_scanner(hass): ), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch( "homeassistant.components.ssdp.Scanner.async_scan" ), patch( - "homeassistant.components.ssdp.Server._async_start_upnp_servers" + "homeassistant.components.ssdp.Server._async_start_upnp_servers", ), patch( - "homeassistant.components.ssdp.Server._async_stop_upnp_servers" + "homeassistant.components.ssdp.Server._async_stop_upnp_servers", ): yield diff --git a/tests/components/yandextts/test_tts.py b/tests/components/yandextts/test_tts.py index d04aef6b16b..a8052e45047 100644 --- a/tests/components/yandextts/test_tts.py +++ b/tests/components/yandextts/test_tts.py @@ -4,7 +4,7 @@ from http import HTTPStatus import pytest -from homeassistant.components import media_source, tts +from homeassistant.components import tts from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, DOMAIN as DOMAIN_MP, @@ -14,7 +14,9 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import assert_setup_component, async_mock_service +from tests.components.tts.common import retrieve_media from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator URL = "https://tts.voicetech.yandex.net/generate?" @@ -30,15 +32,6 @@ def mock_tts_cache_dir_autouse(mock_tts_cache_dir): return mock_tts_cache_dir -async def get_media_source_url(hass, media_content_id): - """Get the media source url.""" - if media_source.DOMAIN not in hass.config.components: - assert await async_setup_component(hass, media_source.DOMAIN, {}) - - resolved = await media_source.async_resolve_media(hass, media_content_id, None) - return resolved.url - - async def test_setup_component(hass: HomeAssistant) -> None: """Test setup component.""" config = {tts.DOMAIN: {"platform": "yandextts", "api_key": "1234567xx"}} @@ -58,7 +51,9 @@ async def test_setup_component_without_api_key(hass: HomeAssistant) -> None: async def test_service_say( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, ) -> None: """Test service call say.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -87,12 +82,18 @@ async def test_service_say( blocking=True, ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + assert len(aioclient_mock.mock_calls) == 1 async def test_service_say_russian_config( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, ) -> None: """Test service call say.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -128,12 +129,18 @@ async def test_service_say_russian_config( ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + assert len(aioclient_mock.mock_calls) == 1 async def test_service_say_russian_service( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, ) -> None: """Test service call say.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -166,12 +173,18 @@ async def test_service_say_russian_service( blocking=True, ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + assert len(aioclient_mock.mock_calls) == 1 async def test_service_say_timeout( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, ) -> None: """Test service call say.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -207,13 +220,18 @@ async def test_service_say_timeout( await hass.async_block_till_done() assert len(calls) == 1 - with pytest.raises(media_source.Unresolvable): - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.NOT_FOUND + ) + assert len(aioclient_mock.mock_calls) == 1 async def test_service_say_http_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, ) -> None: """Test service call say.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -248,12 +266,16 @@ async def test_service_say_http_error( ) assert len(calls) == 1 - with pytest.raises(media_source.Unresolvable): - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.NOT_FOUND + ) async def test_service_say_specified_speaker( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, ) -> None: """Test service call say.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -288,12 +310,18 @@ async def test_service_say_specified_speaker( blocking=True, ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + assert len(aioclient_mock.mock_calls) == 1 async def test_service_say_specified_emotion( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, ) -> None: """Test service call say.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -328,13 +356,18 @@ async def test_service_say_specified_emotion( blocking=True, ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(aioclient_mock.mock_calls) == 1 async def test_service_say_specified_low_speed( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, ) -> None: """Test service call say.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -365,13 +398,18 @@ async def test_service_say_specified_low_speed( blocking=True, ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(aioclient_mock.mock_calls) == 1 async def test_service_say_specified_speed( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, ) -> None: """Test service call say.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -400,13 +438,18 @@ async def test_service_say_specified_speed( blocking=True, ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(aioclient_mock.mock_calls) == 1 async def test_service_say_specified_options( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, ) -> None: """Test service call say with options.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -438,6 +481,9 @@ async def test_service_say_specified_options( blocking=True, ) assert len(calls) == 1 - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) assert len(aioclient_mock.mock_calls) == 1 diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 0bd5b5f59d0..e1d33ee5f75 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -440,9 +440,11 @@ async def test_manual_no_capabilities(hass: HomeAssistant) -> None: ), _patch_discovery_timeout(), _patch_discovery_interval(), patch( f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb ), patch( - f"{MODULE}.async_setup", return_value=True + f"{MODULE}.async_setup", + return_value=True, ), patch( - f"{MODULE}.async_setup_entry", return_value=True + f"{MODULE}.async_setup_entry", + return_value=True, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: IP_ADDRESS} diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 9d9d74e72df..1b3a536007a 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -26,7 +26,9 @@ import zigpy.zdo.types as zdo_t import homeassistant.components.zha.core.const as zha_const import homeassistant.components.zha.core.device as zha_core_device from homeassistant.components.zha.core.helpers import get_zha_gateway +from homeassistant.helpers import restore_state from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util from .common import patch_cluster as common_patch_cluster @@ -44,7 +46,7 @@ def disable_request_retry_delay(): with patch( "homeassistant.components.zha.core.cluster_handlers.RETRYABLE_REQUEST_DECORATOR", zigpy.util.retryable_request(tries=3, delay=0), - ): + ), patch("homeassistant.components.zha.STARTUP_FAILURE_DELAY_S", 0.01): yield @@ -81,8 +83,8 @@ class _FakeApp(ControllerApplication): async def permit_ncp(self, time_s: int = 60): pass - async def permit_with_key( - self, node: zigpy.types.EUI64, code: bytes, time_s: int = 60 + async def permit_with_link_key( + self, node: zigpy.types.EUI64, link_key: zigpy.types.KeyData, time_s: int = 60 ): pass @@ -498,3 +500,35 @@ def network_backup() -> zigpy.backups.NetworkBackup: }, } ) + + +@pytest.fixture +def core_rs(hass_storage): + """Core.restore_state fixture.""" + + def _storage(entity_id, state, attributes={}): + now = dt_util.utcnow().isoformat() + + hass_storage[restore_state.STORAGE_KEY] = { + "version": restore_state.STORAGE_VERSION, + "key": restore_state.STORAGE_KEY, + "data": [ + { + "state": { + "entity_id": entity_id, + "state": str(state), + "attributes": attributes, + "last_changed": now, + "last_updated": now, + "context": { + "id": "3c2243ff5f30447eb12e7348cfd5b8ff", + "user_id": None, + }, + }, + "last_seen": now, + } + ], + } + return + + return _storage diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index b41499dada7..5dd7a5653ec 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -9,8 +9,6 @@ import zigpy.zcl.clusters.security as security from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import restore_state -from homeassistant.util import dt as dt_util from .common import ( async_enable_traffic, @@ -152,38 +150,6 @@ async def test_binary_sensor( assert hass.states.get(entity_id).state == STATE_OFF -@pytest.fixture -def core_rs(hass_storage): - """Core.restore_state fixture.""" - - def _storage(entity_id, attributes, state): - now = dt_util.utcnow().isoformat() - - hass_storage[restore_state.STORAGE_KEY] = { - "version": restore_state.STORAGE_VERSION, - "key": restore_state.STORAGE_KEY, - "data": [ - { - "state": { - "entity_id": entity_id, - "state": str(state), - "attributes": attributes, - "last_changed": now, - "last_updated": now, - "context": { - "id": "3c2243ff5f30447eb12e7348cfd5b8ff", - "user_id": None, - }, - }, - "last_seen": now, - } - ], - } - return - - return _storage - - @pytest.mark.parametrize( "restored_state", [ diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 9ec8048ea03..883df4aba94 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -10,7 +10,7 @@ import pytest import serial.tools.list_ports from zigpy.backups import BackupManager import zigpy.config -from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH +from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH, SCHEMA_DEVICE import zigpy.device from zigpy.exceptions import NetworkNotFormed import zigpy.types @@ -22,7 +22,7 @@ from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_ from homeassistant.components.zha import config_flow, radio_manager from homeassistant.components.zha.core.const import ( CONF_BAUDRATE, - CONF_FLOWCONTROL, + CONF_FLOW_CONTROL, CONF_RADIO_TYPE, DOMAIN, EZSP_OVERWRITE_EUI64, @@ -118,9 +118,7 @@ def mock_detect_radio_type( async def detect(self): self.radio_type = radio_type - self.device_settings = radio_type.controller.SCHEMA_DEVICE( - {CONF_DEVICE_PATH: self.device_path} - ) + self.device_settings = SCHEMA_DEVICE({CONF_DEVICE_PATH: self.device_path}) return ret @@ -181,7 +179,7 @@ async def test_zeroconf_discovery_znp(hass: HomeAssistant) -> None: assert result3["data"] == { CONF_DEVICE: { CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, CONF_DEVICE_PATH: "socket://192.168.1.200:6638", }, CONF_RADIO_TYPE: "znp", @@ -238,6 +236,8 @@ async def test_zigate_via_zeroconf(setup_entry_mock, hass: HomeAssistant) -> Non assert result4["data"] == { CONF_DEVICE: { CONF_DEVICE_PATH: "socket://192.168.1.200:1234", + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "zigate", } @@ -287,7 +287,7 @@ async def test_efr32_via_zeroconf(hass: HomeAssistant) -> None: CONF_DEVICE: { CONF_DEVICE_PATH: "socket://192.168.1.200:1234", CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: "software", + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "ezsp", } @@ -304,7 +304,7 @@ async def test_discovery_via_zeroconf_ip_change(hass: HomeAssistant) -> None: CONF_DEVICE: { CONF_DEVICE_PATH: "socket://192.168.1.5:6638", CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, } }, ) @@ -328,7 +328,7 @@ async def test_discovery_via_zeroconf_ip_change(hass: HomeAssistant) -> None: assert entry.data[CONF_DEVICE] == { CONF_DEVICE_PATH: "socket://192.168.1.22:6638", CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, } @@ -483,6 +483,8 @@ async def test_zigate_discovery_via_usb(probe_mock, hass: HomeAssistant) -> None assert result4["data"] == { "device": { "path": "/dev/ttyZIGBEE", + "baudrate": 115200, + "flow_control": None, }, CONF_RADIO_TYPE: "zigate", } @@ -555,7 +557,7 @@ async def test_discovery_via_usb_path_changes(hass: HomeAssistant) -> None: CONF_DEVICE: { CONF_DEVICE_PATH: "/dev/ttyUSB1", CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, } }, ) @@ -579,7 +581,7 @@ async def test_discovery_via_usb_path_changes(hass: HomeAssistant) -> None: assert entry.data[CONF_DEVICE] == { CONF_DEVICE_PATH: "/dev/ttyZIGBEE", CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, } @@ -754,6 +756,8 @@ async def test_user_flow(hass: HomeAssistant) -> None: assert result2["data"] == { "device": { "path": port.device, + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "deconz", } @@ -773,7 +777,11 @@ async def test_user_flow_not_detected(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, - data={zigpy.config.CONF_DEVICE_PATH: port_select}, + data={ + zigpy.config.CONF_DEVICE_PATH: port_select, + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + }, ) assert result["type"] == FlowResultType.FORM @@ -951,31 +959,6 @@ async def test_user_port_config(probe_mock, hass: HomeAssistant) -> None: assert probe_mock.await_count == 1 -@pytest.mark.parametrize( - ("old_type", "new_type"), - [ - ("ezsp", "ezsp"), - ("ti_cc", "znp"), # only one that should change - ("znp", "znp"), - ("deconz", "deconz"), - ], -) -async def test_migration_ti_cc_to_znp( - old_type, new_type, hass: HomeAssistant, config_entry: MockConfigEntry -) -> None: - """Test zigpy-cc to zigpy-znp config migration.""" - config_entry.data = {**config_entry.data, CONF_RADIO_TYPE: old_type} - config_entry.version = 2 - config_entry.add_to_hass(hass) - - with patch("homeassistant.components.zha.async_setup_entry", return_value=True): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.version > 2 - assert config_entry.data[CONF_RADIO_TYPE] == new_type - - @pytest.mark.parametrize("onboarded", [True, False]) @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_hardware(onboarded, hass: HomeAssistant) -> None: @@ -1022,7 +1005,7 @@ async def test_hardware(onboarded, hass: HomeAssistant) -> None: assert result3["data"] == { CONF_DEVICE: { CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: "hardware", + CONF_FLOW_CONTROL: "hardware", CONF_DEVICE_PATH: "/dev/ttyAMA1", }, CONF_RADIO_TYPE: "ezsp", @@ -1171,6 +1154,7 @@ async def test_formation_strategy_form_initial_network( @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_onboarding_auto_formation_new_hardware( mock_app, hass: HomeAssistant ) -> None: @@ -1577,7 +1561,7 @@ async def test_options_flow_defaults( CONF_DEVICE: { CONF_DEVICE_PATH: "/dev/ttyUSB0", CONF_BAUDRATE: 12345, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "znp", }, @@ -1645,7 +1629,7 @@ async def test_options_flow_defaults( # Change everything CONF_DEVICE_PATH: "/dev/new_serial_port", CONF_BAUDRATE: 54321, - CONF_FLOWCONTROL: "software", + CONF_FLOW_CONTROL: "software", }, ) @@ -1668,7 +1652,7 @@ async def test_options_flow_defaults( CONF_DEVICE: { CONF_DEVICE_PATH: "/dev/new_serial_port", CONF_BAUDRATE: 54321, - CONF_FLOWCONTROL: "software", + CONF_FLOW_CONTROL: "software", }, CONF_RADIO_TYPE: "znp", } @@ -1697,7 +1681,7 @@ async def test_options_flow_defaults_socket(hass: HomeAssistant) -> None: CONF_DEVICE: { CONF_DEVICE_PATH: "socket://localhost:5678", CONF_BAUDRATE: 12345, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "znp", }, @@ -1766,7 +1750,7 @@ async def test_options_flow_restarts_running_zha_if_cancelled( CONF_DEVICE: { CONF_DEVICE_PATH: "socket://localhost:5678", CONF_BAUDRATE: 12345, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "znp", }, @@ -1821,7 +1805,7 @@ async def test_options_flow_migration_reset_old_adapter( CONF_DEVICE: { CONF_DEVICE_PATH: "/dev/serial/by-id/old_radio", CONF_BAUDRATE: 12345, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "znp", }, @@ -1954,3 +1938,28 @@ async def test_discovery_wrong_firmware_installed(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.ABORT assert result["reason"] == "wrong_firmware_installed" + + +@pytest.mark.parametrize( + ("old_type", "new_type"), + [ + ("ezsp", "ezsp"), + ("ti_cc", "znp"), # only one that should change + ("znp", "znp"), + ("deconz", "deconz"), + ], +) +async def test_migration_ti_cc_to_znp( + old_type: str, new_type: str, hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test zigpy-cc to zigpy-znp config migration.""" + config_entry.data = {**config_entry.data, CONF_RADIO_TYPE: old_type} + config_entry.version = 2 + config_entry.add_to_hass(hass) + + with patch("homeassistant.components.zha.async_setup_entry", return_value=True): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.version > 2 + assert config_entry.data[CONF_RADIO_TYPE] == new_type diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 737604482d8..7d45960d576 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -222,10 +222,11 @@ async def test_fan( # set invalid preset_mode from HA cluster.write_attributes.reset_mock() - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await async_set_preset_mode( hass, entity_id, preset_mode="invalid does not exist" ) + assert exc.value.translation_key == "not_valid_preset_mode" assert len(cluster.write_attributes.mock_calls) == 0 # test adding new fan to the network and HA @@ -624,10 +625,11 @@ async def test_fan_ikea( # set invalid preset_mode from HA cluster.write_attributes.reset_mock() - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await async_set_preset_mode( hass, entity_id, preset_mode="invalid does not exist" ) + assert exc.value.translation_key == "not_valid_preset_mode" assert len(cluster.write_attributes.mock_calls) == 0 # test adding new fan to the network and HA @@ -813,8 +815,9 @@ async def test_fan_kof( # set invalid preset_mode from HA cluster.write_attributes.reset_mock() - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_AUTO) + assert exc.value.translation_key == "not_valid_preset_mode" assert len(cluster.write_attributes.mock_calls) == 0 # test adding new fan to the network and HA diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 2a0a241c864..4f520920704 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -4,22 +4,21 @@ from unittest.mock import MagicMock, patch import pytest from zigpy.application import ControllerApplication -import zigpy.exceptions import zigpy.profiles.zha as zha import zigpy.zcl.clusters.general as general import zigpy.zcl.clusters.lighting as lighting -from homeassistant.components.zha.core.const import RadioType -from homeassistant.components.zha.core.device import ZHADevice +from homeassistant.components.zha.core.gateway import ZHAGateway from homeassistant.components.zha.core.group import GroupMember from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from .common import async_find_group_entity_id from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +from tests.common import MockConfigEntry + IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e8" @@ -224,101 +223,6 @@ async def test_gateway_create_group_with_id( assert zha_group.group_id == 0x1234 -@patch( - "homeassistant.components.zha.core.gateway.ZHAGateway.async_load_devices", - MagicMock(), -) -@patch( - "homeassistant.components.zha.core.gateway.ZHAGateway.async_load_groups", - MagicMock(), -) -@patch("homeassistant.components.zha.core.gateway.STARTUP_FAILURE_DELAY_S", 0.01) -@pytest.mark.parametrize( - "startup_effect", - [ - [asyncio.TimeoutError(), FileNotFoundError(), None], - [asyncio.TimeoutError(), None], - [None], - ], -) -async def test_gateway_initialize_success( - startup_effect: list[Exception | None], - hass: HomeAssistant, - device_light_1: ZHADevice, - coordinator: ZHADevice, - zigpy_app_controller: ControllerApplication, -) -> None: - """Test ZHA initializing the gateway successfully.""" - zha_gateway = get_zha_gateway(hass) - assert zha_gateway is not None - - zigpy_app_controller.startup.side_effect = startup_effect - zigpy_app_controller.startup.reset_mock() - - with patch( - "bellows.zigbee.application.ControllerApplication.new", - return_value=zigpy_app_controller, - ): - await zha_gateway.async_initialize() - - assert zigpy_app_controller.startup.call_count == len(startup_effect) - device_light_1.async_cleanup_handles() - - -@patch("homeassistant.components.zha.core.gateway.STARTUP_FAILURE_DELAY_S", 0.01) -async def test_gateway_initialize_failure( - hass: HomeAssistant, - device_light_1: ZHADevice, - coordinator: ZHADevice, - zigpy_app_controller: ControllerApplication, -) -> None: - """Test ZHA failing to initialize the gateway.""" - zha_gateway = get_zha_gateway(hass) - assert zha_gateway is not None - - zigpy_app_controller.startup.side_effect = [ - asyncio.TimeoutError(), - RuntimeError(), - FileNotFoundError(), - ] - zigpy_app_controller.startup.reset_mock() - - with patch( - "bellows.zigbee.application.ControllerApplication.new", - return_value=zigpy_app_controller, - ), pytest.raises(FileNotFoundError): - await zha_gateway.async_initialize() - - assert zigpy_app_controller.startup.call_count == 3 - - -@patch("homeassistant.components.zha.core.gateway.STARTUP_FAILURE_DELAY_S", 0.01) -async def test_gateway_initialize_failure_transient( - hass: HomeAssistant, - device_light_1: ZHADevice, - coordinator: ZHADevice, - zigpy_app_controller: ControllerApplication, -) -> None: - """Test ZHA failing to initialize the gateway but with a transient error.""" - zha_gateway = get_zha_gateway(hass) - assert zha_gateway is not None - - zigpy_app_controller.startup.side_effect = [ - RuntimeError(), - zigpy.exceptions.TransientConnectionError(), - ] - zigpy_app_controller.startup.reset_mock() - - with patch( - "bellows.zigbee.application.ControllerApplication.new", - return_value=zigpy_app_controller, - ), pytest.raises(ConfigEntryNotReady): - await zha_gateway.async_initialize() - - # Initialization immediately stops and is retried after TransientConnectionError - assert zigpy_app_controller.startup.call_count == 2 - - @patch( "homeassistant.components.zha.core.gateway.ZHAGateway.async_load_devices", MagicMock(), @@ -340,22 +244,25 @@ async def test_gateway_initialize_bellows_thread( thread_state: bool, config_override: dict, hass: HomeAssistant, - coordinator: ZHADevice, zigpy_app_controller: ControllerApplication, + config_entry: MockConfigEntry, ) -> None: """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" - zha_gateway = get_zha_gateway(hass) - assert zha_gateway is not None + config_entry.data = dict(config_entry.data) + config_entry.data["device"]["path"] = device_path + config_entry.add_to_hass(hass) - zha_gateway.config_entry.data = dict(zha_gateway.config_entry.data) - zha_gateway.config_entry.data["device"]["path"] = device_path - zha_gateway._config.setdefault("zigpy_config", {}).update(config_override) + zha_gateway = ZHAGateway(hass, {"zigpy_config": config_override}, config_entry) - await zha_gateway.async_initialize() + with patch( + "bellows.zigbee.application.ControllerApplication.new", + return_value=zigpy_app_controller, + ) as mock_new: + await zha_gateway.async_initialize() - RadioType.ezsp.controller.new.mock_calls[-1].kwargs["config"][ - "use_thread" - ] is thread_state + mock_new.mock_calls[-1].kwargs["config"]["use_thread"] is thread_state + + await zha_gateway.shutdown() @pytest.mark.parametrize( @@ -373,15 +280,14 @@ async def test_gateway_force_multi_pan_channel( config_override: dict, expected_channel: int | None, hass: HomeAssistant, - coordinator, + config_entry: MockConfigEntry, ) -> None: """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" - zha_gateway = get_zha_gateway(hass) - assert zha_gateway is not None + config_entry.data = dict(config_entry.data) + config_entry.data["device"]["path"] = device_path + config_entry.add_to_hass(hass) - zha_gateway.config_entry.data = dict(zha_gateway.config_entry.data) - zha_gateway.config_entry.data["device"]["path"] = device_path - zha_gateway._config.setdefault("zigpy_config", {}).update(config_override) + zha_gateway = ZHAGateway(hass, {"zigpy_config": config_override}, config_entry) _, config = zha_gateway.get_application_controller_data() assert config["network"]["channel"] == expected_channel diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index ad6ab4e351e..c2e9469c239 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -1,5 +1,6 @@ """Tests for ZHA integration init.""" import asyncio +import typing from unittest.mock import AsyncMock, Mock, patch import pytest @@ -9,6 +10,7 @@ from zigpy.exceptions import TransientConnectionError from homeassistant.components.zha.core.const import ( CONF_BAUDRATE, + CONF_FLOW_CONTROL, CONF_RADIO_TYPE, CONF_USB_PATH, DOMAIN, @@ -61,9 +63,8 @@ async def test_migration_from_v1_no_baudrate( assert config_entry_v1.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE assert CONF_DEVICE in config_entry_v1.data assert config_entry_v1.data[CONF_DEVICE][CONF_DEVICE_PATH] == DATA_PORT_PATH - assert CONF_BAUDRATE not in config_entry_v1.data[CONF_DEVICE] assert CONF_USB_PATH not in config_entry_v1.data - assert config_entry_v1.version == 3 + assert config_entry_v1.version == 4 @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @@ -80,7 +81,7 @@ async def test_migration_from_v1_with_baudrate( assert CONF_USB_PATH not in config_entry_v1.data assert CONF_BAUDRATE in config_entry_v1.data[CONF_DEVICE] assert config_entry_v1.data[CONF_DEVICE][CONF_BAUDRATE] == 115200 - assert config_entry_v1.version == 3 + assert config_entry_v1.version == 4 @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @@ -95,8 +96,7 @@ async def test_migration_from_v1_wrong_baudrate( assert CONF_DEVICE in config_entry_v1.data assert config_entry_v1.data[CONF_DEVICE][CONF_DEVICE_PATH] == DATA_PORT_PATH assert CONF_USB_PATH not in config_entry_v1.data - assert CONF_BAUDRATE not in config_entry_v1.data[CONF_DEVICE] - assert config_entry_v1.version == 3 + assert config_entry_v1.version == 4 @pytest.mark.skipif( @@ -149,23 +149,74 @@ async def test_setup_with_v3_cleaning_uri( mock_zigpy_connect: ControllerApplication, ) -> None: """Test migration of config entry from v3, applying corrections to the port path.""" - config_entry_v3 = MockConfigEntry( + config_entry_v4 = MockConfigEntry( domain=DOMAIN, data={ CONF_RADIO_TYPE: DATA_RADIO_TYPE, - CONF_DEVICE: {CONF_DEVICE_PATH: path, CONF_BAUDRATE: 115200}, + CONF_DEVICE: { + CONF_DEVICE_PATH: path, + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + }, }, - version=3, + version=4, ) - config_entry_v3.add_to_hass(hass) + config_entry_v4.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry_v3.entry_id) + await hass.config_entries.async_setup(config_entry_v4.entry_id) await hass.async_block_till_done() - await hass.config_entries.async_unload(config_entry_v3.entry_id) + await hass.config_entries.async_unload(config_entry_v4.entry_id) - assert config_entry_v3.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE - assert config_entry_v3.data[CONF_DEVICE][CONF_DEVICE_PATH] == cleaned_path - assert config_entry_v3.version == 3 + assert config_entry_v4.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE + assert config_entry_v4.data[CONF_DEVICE][CONF_DEVICE_PATH] == cleaned_path + assert config_entry_v4.version == 4 + + +@pytest.mark.parametrize( + ( + "radio_type", + "old_baudrate", + "old_flow_control", + "new_baudrate", + "new_flow_control", + ), + [ + ("znp", None, None, 115200, None), + ("znp", None, "software", 115200, "software"), + ("znp", 57600, "software", 57600, "software"), + ("deconz", None, None, 38400, None), + ("deconz", 115200, None, 115200, None), + ], +) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_migration_baudrate_and_flow_control( + radio_type: str, + old_baudrate: int, + old_flow_control: typing.Literal["hardware", "software", None], + new_baudrate: int, + new_flow_control: typing.Literal["hardware", "software", None], + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test baudrate and flow control migration.""" + config_entry.data = { + **config_entry.data, + CONF_RADIO_TYPE: radio_type, + CONF_DEVICE: { + CONF_BAUDRATE: old_baudrate, + CONF_FLOW_CONTROL: old_flow_control, + CONF_DEVICE_PATH: "/dev/null", + }, + } + config_entry.version = 3 + 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.version > 3 + assert config_entry.data[CONF_DEVICE][CONF_BAUDRATE] == new_baudrate + assert config_entry.data[CONF_DEVICE][CONF_FLOW_CONTROL] == new_flow_control @patch( diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index 1ec70b74735..bd799187a19 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -40,7 +40,10 @@ from .common import ( ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE -from tests.common import async_fire_time_changed +from tests.common import ( + async_fire_time_changed, + async_mock_load_restore_state_from_storage, +) IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e9" @@ -1921,3 +1924,76 @@ async def test_group_member_assume_state( await zha_gateway.async_remove_zigpy_group(zha_group.group_id) assert hass.states.get(group_entity_id) is None assert entity_registry.async_get(group_entity_id) is None + + +@pytest.mark.parametrize( + ("restored_state", "expected_state"), + [ + ( + STATE_ON, + { + "brightness": None, + "off_with_transition": None, + "off_brightness": None, + "color_mode": ColorMode.XY, # color_mode defaults to what the light supports when restored with ON state + "color_temp": None, + "xy_color": None, + "hs_color": None, + "effect": None, + }, + ), + ( + STATE_OFF, + { + "brightness": None, + "off_with_transition": None, + "off_brightness": None, + "color_mode": None, + "color_temp": None, + "xy_color": None, + "hs_color": None, + "effect": None, + }, + ), + ], +) +async def test_restore_light_state( + hass: HomeAssistant, + zigpy_device_mock, + core_rs, + zha_device_restored, + restored_state, + expected_state, +) -> None: + """Test ZHA light restores without throwing an error when attributes are None.""" + + # restore state with None values + attributes = { + "brightness": None, + "off_with_transition": None, + "off_brightness": None, + "color_mode": None, + "color_temp": None, + "xy_color": None, + "hs_color": None, + "effect": None, + } + + entity_id = "light.fakemanufacturer_fakemodel_light" + core_rs( + entity_id, + state=restored_state, + attributes=attributes, + ) + await async_mock_load_restore_state_from_storage(hass) + + zigpy_device = zigpy_device_mock(LIGHT_COLOR) + zha_device = await zha_device_restored(zigpy_device) + entity_id = find_entity_id(Platform.LIGHT, zha_device, hass) + + assert entity_id is not None + assert hass.states.get(entity_id).state == restored_state + + # compare actual restored state to expected state + for attribute, expected_value in expected_state.items(): + assert hass.states.get(entity_id).attributes.get(attribute) == expected_value diff --git a/tests/components/zha/test_repairs.py b/tests/components/zha/test_repairs.py index 9c79578843c..d168e2e57b1 100644 --- a/tests/components/zha/test_repairs.py +++ b/tests/components/zha/test_repairs.py @@ -95,6 +95,7 @@ def test_detect_radio_hardware_failure(hass: HomeAssistant) -> None: assert _detect_radio_hardware(hass, SKYCONNECT_DEVICE) == HardwareType.OTHER +@patch("homeassistant.components.zha.STARTUP_RETRIES", new=1) @pytest.mark.parametrize( ("detected_hardware", "expected_learn_more_url"), [ @@ -188,6 +189,7 @@ async def test_multipan_firmware_no_repair_on_probe_failure( assert issue is None +@patch("homeassistant.components.zha.STARTUP_RETRIES", new=1) async def test_multipan_firmware_retry_on_probe_ezsp( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -312,6 +314,8 @@ async def test_inconsistent_settings_keep_new( data = await resp.json() assert data["type"] == "create_entry" + await hass.config_entries.async_unload(config_entry.entry_id) + assert ( issue_registry.async_get_issue( domain=DOMAIN, @@ -388,6 +392,8 @@ async def test_inconsistent_settings_restore_old( data = await resp.json() assert data["type"] == "create_entry" + await hass.config_entries.async_unload(config_entry.entry_id) + assert ( issue_registry.async_get_issue( domain=DOMAIN, diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index d914c88c0c2..44006ea6ca1 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -62,7 +62,7 @@ from .conftest import ( ) from .data import BASE_CUSTOM_CONFIGURATION, CONFIG_WITH_ALARM_OPTIONS -from tests.common import MockUser +from tests.common import MockConfigEntry, MockUser IEEE_SWITCH_DEVICE = "01:2d:6f:00:0a:90:69:e7" IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" @@ -295,10 +295,12 @@ async def test_get_zha_config_with_alarm( async def test_update_zha_config( - zha_client, app_controller: ControllerApplication + hass: HomeAssistant, + config_entry: MockConfigEntry, + zha_client, + app_controller: ControllerApplication, ) -> None: """Test updating ZHA custom configuration.""" - configuration: dict = deepcopy(CONFIG_WITH_ALARM_OPTIONS) configuration["data"]["zha_options"]["default_light_transition"] = 10 @@ -312,10 +314,12 @@ async def test_update_zha_config( msg = await zha_client.receive_json() assert msg["success"] - await zha_client.send_json({ID: 6, TYPE: "zha/configuration"}) - msg = await zha_client.receive_json() - configuration = msg["result"] - assert configuration == configuration + await zha_client.send_json({ID: 6, TYPE: "zha/configuration"}) + msg = await zha_client.receive_json() + configuration = msg["result"] + assert configuration == configuration + + await hass.config_entries.async_unload(config_entry.entry_id) async def test_device_not_found(zha_client) -> None: diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 44f01555b19..65ef55c4711 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -1492,7 +1492,7 @@ DEVICES = [ DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level"], - DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_CLASS: "ForceOnLight", DEV_SIG_ENT_MAP_ID: "light.jasco_products_45852_light", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { @@ -1547,7 +1547,7 @@ DEVICES = [ DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CLUSTER_HANDLERS: ["on_off"], - DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_CLASS: "ForceOnLight", DEV_SIG_ENT_MAP_ID: "light.jasco_products_45856_light", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { @@ -1602,7 +1602,7 @@ DEVICES = [ DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level"], - DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_CLASS: "ForceOnLight", DEV_SIG_ENT_MAP_ID: "light.jasco_products_45857_light", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { @@ -2178,6 +2178,16 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_power_factor", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_summation_delivered", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_instantaneous_demand", + }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 5a424b38c5b..f2c3abd362a 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -385,6 +385,12 @@ def climate_eurotronic_spirit_z_state_fixture(): return json.loads(load_fixture("zwave_js/climate_eurotronic_spirit_z_state.json")) +@pytest.fixture(name="climate_heatit_z_trm6_state", scope="session") +def climate_heatit_z_trm6_state_fixture(): + """Load the climate HEATIT Z-TRM6 thermostat node state fixture data.""" + return json.loads(load_fixture("zwave_js/climate_heatit_z_trm6_state.json")) + + @pytest.fixture(name="climate_heatit_z_trm3_state", scope="session") def climate_heatit_z_trm3_state_fixture(): """Load the climate HEATIT Z-TRM3 thermostat node state fixture data.""" @@ -897,6 +903,14 @@ def climate_eurotronic_spirit_z_fixture(client, climate_eurotronic_spirit_z_stat return node +@pytest.fixture(name="climate_heatit_z_trm6") +def climate_heatit_z_trm6_fixture(client, climate_heatit_z_trm6_state): + """Mock a climate radio HEATIT Z-TRM6 node.""" + node = Node(client, copy.deepcopy(climate_heatit_z_trm6_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="climate_heatit_z_trm3_no_value") def climate_heatit_z_trm3_no_value_fixture( client, climate_heatit_z_trm3_no_value_state diff --git a/tests/components/zwave_js/fixtures/climate_heatit_z_trm6_state.json b/tests/components/zwave_js/fixtures/climate_heatit_z_trm6_state.json new file mode 100644 index 00000000000..ffc7b25fda4 --- /dev/null +++ b/tests/components/zwave_js/fixtures/climate_heatit_z_trm6_state.json @@ -0,0 +1,2120 @@ +{ + "nodeId": 101, + "index": 0, + "installerIcon": 4608, + "userIcon": 4609, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": true, + "manufacturerId": 411, + "productId": 12289, + "productType": 48, + "firmwareVersion": "1.0.6", + "zwavePlusVersion": 2, + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/data/db/devices/0x019b/z-trm6.json", + "isEmbedded": true, + "manufacturer": "Heatit", + "manufacturerId": 411, + "label": "Z-TRM6", + "description": "Floor Thermostat", + "devices": [ + { + "productType": 48, + "productId": 12289 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "compat": { + "overrideFloatEncoding": { + "size": 2 + } + }, + "metadata": { + "inclusion": "Add\nThe primary controller/gateway has a mode for adding devices. Please refer to your primary controller manual on how to set the primary controller in add mode. The device may only be added to the network if the primary controller is in add mode.\nAn always listening node must be powered continuously and reside in a fixed position in the installation to secure the routing table. Adding the device within a 2 meter range from the gateway can minimize faults during the Interview process.\n\nStandard (Manual)\nAdd mode is indicated on the device by rotating LED segments on the display. It indicates this for 90 seconds until a timeout occurs, or until the device has been added to the network. Configuration mode can also be cancelled by performing the same procedure used for starting\nConfiguration mode.\n1. Hold the Center button for 5 seconds.\nThe display will show \u201cOFF\u201d.\n2. Press the \u201d+\u201d button once to see \u201cCON\u201d in the display.\n3. Start the add device process in your primary controller.\n4. Start the configuration mode on the thermostat by holding the Center button for approximately 2 seconds.\n\nThe device is now ready for use with default settings.\nIf inclusion fails, please perform a \u201dremove device\u201d process and try again. If inclusion fails again, please see \u201cFactory reset\u201d", + "exclusion": "Remove\nThe primary controller/gateway has a mode for removing devices. Please refer to your primary controller manual on how to set the primary controller in remove mode. The device may only be removed from the network if the primary controller is in remove mode.\nWhen the device is removed from the network, it will NOT revert to factory settings.\n\nStandard (Manual)\nRemove mode is indicated on the device by rotating LED segments on the display. It indicates this for 90 seconds until a timeout occurs, or until the device has been removed from the network. Configuration mode can also be cancelled by performing the same procedure used for starting\nConfiguration mode.\n1. Hold the Center button for 5 seconds.\nThe display will show \u201cOFF\u201d.\n2. Press the \u201d+\u201d button once to see \u201cCON\u201d in the display.\n3. Start the remove device process in your primary controller.\n4. Start the configuration mode on the thermostat by holding the Center button for approximately 2 seconds.\n\nNB! When the device is removed from the gateway, the parameters are not reset. To reset the parameters, see Chapter \u201dFactory reset\u201d", + "reset": "Enter the menu by holding the Center button for about 5 seconds, navigate in the menu with the \u201d+\u201d button til you see FACT. Press the Center button until you see \u201c-- --\u201d blinking in the display, then hold for about 5 seconds to perform a reset.\nYou may also initiate a reset by holding the Right and Center buttons for 60 seconds.\n\nWhen either of these procedures has been performed, the thermostat will perform a complete factory reset. The device will display \u201cRES\u201d for 5 seconds while performing a factory reset. When \u201cRES\u201d is no longer displayed, the thermostat has been reset.\n\nPlease use this procedure only when the network primary controller is missing or otherwise inoperable", + "manual": "https://media.heatit.com/2926" + } + }, + "label": "Z-TRM6", + "endpointCountIsDynamic": false, + "endpointsHaveIdenticalCapabilities": false, + "individualEndpointCount": 4, + "aggregatedEndpointCount": 0, + "interviewAttempts": 1, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 8, + "label": "Thermostat" + }, + "specific": { + "key": 6, + "label": "General Thermostat V2" + }, + "mandatorySupportedCCs": [32, 114, 64, 67, 134], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x019b:0x0030:0x3001:1.0.6", + "statistics": { + "commandsTX": 268, + "commandsRX": 399, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 4, + "lastSeen": "2023-11-20T16:45:28.117Z", + "lwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -51, + "repeaterRSSI": [] + }, + "rtt": 32.4, + "rssi": -50 + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2023-11-20T16:45:28.117Z", + "values": [ + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Local Control", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Local Control", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Enable", + "1": "Disable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Sensor Mode", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Sensor Mode", + "default": 1, + "min": 0, + "max": 5, + "states": { + "0": "Floor", + "1": "Internal", + "2": "Internal with floor limit", + "3": "External", + "4": "External with floor limit", + "5": "Power regulator" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "External Sensor Resistance", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "External Sensor Resistance", + "default": 0, + "min": 0, + "max": 7, + "states": { + "0": "10", + "1": "12", + "2": "15", + "3": "22", + "4": "33", + "5": "47", + "6": "6.8", + "7": "100" + }, + "unit": "k\u03a9", + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Internal Sensor Min Temp Limit", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Internal Sensor Min Temp Limit", + "default": 50, + "min": 50, + "max": 400, + "unit": "0.1 \u00b0C", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 50 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "Floor Sensor Min Temp Limit", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Floor Sensor Min Temp Limit", + "default": 50, + "min": 50, + "max": 400, + "unit": "0.1 \u00b0C", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 50 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "External Sensor Min Temp Limit", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "External Sensor Min Temp Limit", + "default": 50, + "min": 50, + "max": 400, + "unit": "0.1 \u00b0C", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 50 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Internal Sensor Max Temp Limit", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Internal Sensor Max Temp Limit", + "default": 400, + "min": 50, + "max": 400, + "unit": "0.1 \u00b0C", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 400 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Floor Sensor Max Temp Limit", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Floor Sensor Max Temp Limit", + "default": 400, + "min": 50, + "max": 400, + "unit": "0.1 \u00b0C", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 400 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 9, + "propertyName": "External Sensor Max Temp Limit", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "External Sensor Max Temp Limit", + "default": 400, + "min": 50, + "max": 400, + "unit": "0.1 \u00b0C", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 400 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 10, + "propertyName": "Internal Sensor Calibration", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Internal Sensor Calibration", + "default": 0, + "min": -60, + "max": 60, + "unit": "0.1 \u00b0C", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 11, + "propertyName": "Floor Sensor Calibration", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Floor Sensor Calibration", + "default": 0, + "min": -60, + "max": 60, + "unit": "0.1 \u00b0C", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 12, + "propertyName": "External Sensor Calibration", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "External Sensor Calibration", + "default": 0, + "min": -60, + "max": 60, + "unit": "0.1 \u00b0C", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 13, + "propertyName": "Regulation Mode", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Regulation Mode", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Hysteresis", + "1": "PWM" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 14, + "propertyName": "Temperature Control Hysteresis", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Control Hysteresis", + "default": 5, + "min": 3, + "max": 30, + "unit": "0.1 \u00b0C", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyName": "Temperature Display", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Display", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Setpoint", + "1": "Measured" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 16, + "propertyName": "Active Display Brightness", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Active Display Brightness", + "default": 10, + "min": 1, + "max": 10, + "unit": "10 %", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 17, + "propertyName": "Standby Display Brightness", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Standby Display Brightness", + "default": 5, + "min": 1, + "max": 10, + "unit": "10 %", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 18, + "propertyName": "Temperature Report Interval", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Report Interval", + "default": 840, + "min": 30, + "max": 65535, + "unit": "seconds", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 840 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 19, + "propertyName": "Temperature Report Hysteresis", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Report Hysteresis", + "default": 10, + "min": 1, + "max": 100, + "unit": "0.1 \u00b0C", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 20, + "propertyName": "Meter Report Interval", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Meter Report Interval", + "default": 840, + "min": 30, + "max": 65535, + "unit": "seconds", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 840 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 21, + "propertyName": "Turn On Delay After Error", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Turn On Delay After Error", + "default": 0, + "min": 0, + "max": 65535, + "states": { + "0": "Stay off (Display error)" + }, + "unit": "seconds", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyName": "Heating Setpoint", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Heating Setpoint", + "default": 210, + "min": 50, + "max": 400, + "unit": "0.1 \u00b0C", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 190 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyName": "Cooling Setpoint", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Cooling Setpoint", + "default": 180, + "min": 50, + "max": 400, + "unit": "0.1 \u00b0C", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 180 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 24, + "propertyName": "Eco Setpoint", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Eco Setpoint", + "default": 180, + "min": 50, + "max": 400, + "unit": "0.1 \u00b0C", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 180 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 25, + "propertyName": "Power Regulator Active Time", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Power Regulator Active Time", + "default": 2, + "min": 1, + "max": 10, + "unit": "10 %", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 26, + "propertyName": "Thermostat State Report Interval", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Thermostat State Report Interval", + "default": 43200, + "min": 0, + "max": 65535, + "states": { + "0": "Changes only" + }, + "unit": "seconds", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 43200 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 27, + "propertyName": "Operating Mode", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Operating Mode", + "default": 1, + "min": 0, + "max": 3, + "states": { + "0": "Off", + "1": "Heating", + "2": "Cooling", + "3": "Eco" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 28, + "propertyName": "Open Window Detection", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Open Window Detection", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disabled", + "1": "Enabled" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 29, + "propertyName": "Load Power", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Load Power", + "default": 0, + "min": 0, + "max": 99, + "states": { + "0": "Use measured value" + }, + "unit": "100 W", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 411 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 48 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 12289 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.18" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["1.0", "2.5"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "7.18.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "10.18.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 273 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.18.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 273 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "1.0.6" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 273 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 3, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "Node Identify - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 4, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "Node Identify - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 5, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "Node Identify - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "value", + "propertyName": "value", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Indicator value", + "ccSpecific": { + "indicatorId": 0 + }, + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "identify", + "propertyName": "identify", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Identify", + "states": { + "true": "Identify" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": true, + "label": "Timeout", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kWh_Consumed", + "ccVersion": 5, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [kWh]", + "ccSpecific": { + "meterType": 1, + "scale": 0, + "rateType": 1 + }, + "unit": "kWh", + "stateful": true, + "secret": false + }, + "value": 0, + "nodeId": 101 + }, + { + "endpoint": 1, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66049, + "propertyName": "value", + "propertyKeyName": "Electric_W_Consumed", + "ccVersion": 5, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [W]", + "ccSpecific": { + "meterType": 1, + "scale": 2, + "rateType": 1 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "value": 0, + "nodeId": 101 + }, + { + "endpoint": 1, + "commandClass": 50, + "commandClassName": "Meter", + "property": "reset", + "propertyName": "reset", + "ccVersion": 5, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Reset accumulated values", + "states": { + "true": "Reset" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 64, + "commandClassName": "Thermostat Mode", + "property": "mode", + "propertyName": "mode", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Thermostat mode", + "min": 0, + "max": 255, + "states": { + "0": "Off", + "1": "Heat", + "2": "Cool", + "11": "Energy heat" + }, + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 1, + "commandClass": 64, + "commandClassName": "Thermostat Mode", + "property": "manufacturerData", + "propertyName": "manufacturerData", + "ccVersion": 3, + "metadata": { + "type": "buffer", + "readable": true, + "writeable": false, + "label": "Manufacturer data", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 66, + "commandClassName": "Thermostat Operating State", + "property": "state", + "propertyName": "state", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Operating state", + "min": 0, + "max": 255, + "states": { + "0": "Idle", + "1": "Heating", + "2": "Cooling", + "3": "Fan Only", + "4": "Pending Heat", + "5": "Pending Cool", + "6": "Vent/Economizer", + "7": "Aux Heating", + "8": "2nd Stage Heating", + "9": "2nd Stage Cooling", + "10": "2nd Stage Aux Heat", + "11": "3rd Stage Aux Heat" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 67, + "commandClassName": "Thermostat Setpoint", + "property": "setpoint", + "propertyKey": 1, + "propertyName": "setpoint", + "propertyKeyName": "Heating", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Setpoint (Heating)", + "ccSpecific": { + "setpointType": 1 + }, + "min": 5, + "max": 40, + "unit": "\u00b0C", + "stateful": true, + "secret": false + }, + "value": 19 + }, + { + "endpoint": 1, + "commandClass": 67, + "commandClassName": "Thermostat Setpoint", + "property": "setpoint", + "propertyKey": 2, + "propertyName": "setpoint", + "propertyKeyName": "Cooling", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Setpoint (Cooling)", + "ccSpecific": { + "setpointType": 2 + }, + "min": 5, + "max": 40, + "unit": "\u00b0C", + "stateful": true, + "secret": false + }, + "value": 18 + }, + { + "endpoint": 1, + "commandClass": 67, + "commandClassName": "Thermostat Setpoint", + "property": "setpoint", + "propertyKey": 11, + "propertyName": "setpoint", + "propertyKeyName": "Energy Save Heating", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Setpoint (Energy Save Heating)", + "ccSpecific": { + "setpointType": 11 + }, + "min": 5, + "max": 40, + "unit": "\u00b0C", + "stateful": true, + "secret": false + }, + "value": 18 + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Heat Alarm", + "propertyKey": "Heat sensor status", + "propertyName": "Heat Alarm", + "propertyKeyName": "Heat sensor status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Heat sensor status", + "ccSpecific": { + "notificationType": 4 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "2": "Overheat detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Over-load status", + "propertyName": "Power Management", + "propertyKeyName": "Over-load status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Over-load status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "8": "Over-load detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 117, + "commandClassName": "Protection", + "property": "local", + "propertyName": "local", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Local protection state", + "states": { + "0": "Unprotected", + "1": "ProtectedBySequence", + "2": "NoOperationPossible" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 2, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 11, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 0 + }, + "unit": "\u00b0C", + "stateful": true, + "secret": false + }, + "value": 22.5, + "nodeId": 101 + }, + { + "endpoint": 3, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 11, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 0 + }, + "unit": "\u00b0C", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 4, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 11, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 0 + }, + "unit": "\u00b0C", + "stateful": true, + "secret": false + }, + "value": 21.9, + "nodeId": 101 + } + ], + "endpoints": [ + { + "nodeId": 101, + "index": 0, + "installerIcon": 4608, + "userIcon": 4609, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 8, + "label": "Thermostat" + }, + "specific": { + "key": 6, + "label": "General Thermostat V2" + }, + "mandatorySupportedCCs": [32, 114, 64, 67, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 64, + "name": "Thermostat Mode", + "version": 3, + "isSecure": true + }, + { + "id": 67, + "name": "Thermostat Setpoint", + "version": 3, + "isSecure": true + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": true + }, + { + "id": 66, + "name": "Thermostat Operating State", + "version": 1, + "isSecure": true + }, + { + "id": 50, + "name": "Meter", + "version": 5, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 117, + "name": "Protection", + "version": 1, + "isSecure": true + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 11, + "isSecure": true + }, + { + "id": 96, + "name": "Multi Channel", + "version": 4, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 135, + "name": "Indicator", + "version": 3, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": true + } + ] + }, + { + "nodeId": 101, + "index": 1, + "installerIcon": 4608, + "userIcon": 4609, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 8, + "label": "Thermostat" + }, + "specific": { + "key": 6, + "label": "General Thermostat V2" + }, + "mandatorySupportedCCs": [32, 114, 64, 67, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 64, + "name": "Thermostat Mode", + "version": 3, + "isSecure": true + }, + { + "id": 67, + "name": "Thermostat Setpoint", + "version": 3, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 66, + "name": "Thermostat Operating State", + "version": 1, + "isSecure": true + }, + { + "id": 117, + "name": "Protection", + "version": 1, + "isSecure": true + }, + { + "id": 50, + "name": "Meter", + "version": 5, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + } + ] + }, + { + "nodeId": 101, + "index": 2, + "installerIcon": 3328, + "userIcon": 3329, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 33, + "label": "Multilevel Sensor" + }, + "specific": { + "key": 1, + "label": "Routing Multilevel Sensor" + }, + "mandatorySupportedCCs": [32, 49], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 49, + "name": "Multilevel Sensor", + "version": 11, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + } + ] + }, + { + "nodeId": 101, + "index": 3, + "installerIcon": 3328, + "userIcon": 3329, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 33, + "label": "Multilevel Sensor" + }, + "specific": { + "key": 1, + "label": "Routing Multilevel Sensor" + }, + "mandatorySupportedCCs": [32, 49], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 49, + "name": "Multilevel Sensor", + "version": 11, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + } + ] + }, + { + "nodeId": 101, + "index": 4, + "installerIcon": 3328, + "userIcon": 3329, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 33, + "label": "Multilevel Sensor" + }, + "specific": { + "key": 1, + "label": "Routing Multilevel Sensor" + }, + "mandatorySupportedCCs": [32, 49], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 49, + "name": "Multilevel Sensor", + "version": 11, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + } + ] + } + ] +} diff --git a/tests/components/zwave_js/fixtures/multisensor_6_state.json b/tests/components/zwave_js/fixtures/multisensor_6_state.json index 580393ae6cd..5dc34c2f3ac 100644 --- a/tests/components/zwave_js/fixtures/multisensor_6_state.json +++ b/tests/components/zwave_js/fixtures/multisensor_6_state.json @@ -62,7 +62,14 @@ "index": 0, "installerIcon": 3079, "userIcon": 3079, - "commandClasses": [] + "commandClasses": [ + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": false + } + ] } ], "values": [ diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 9c4a6339a78..aa20bd3bb84 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -457,7 +457,7 @@ async def test_node_metadata( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_node_comments( +async def test_node_alerts( hass: HomeAssistant, wallmote_central_scene, integration, @@ -473,13 +473,14 @@ async def test_node_comments( await ws_client.send_json( { ID: 3, - TYPE: "zwave_js/node_comments", + TYPE: "zwave_js/node_alerts", DEVICE_ID: device.id, } ) msg = await ws_client.receive_json() result = msg["result"] assert result["comments"] == [{"level": "info", "text": "test"}] + assert result["is_embedded"] async def test_add_node( diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index e9040dfd397..d5619ff014c 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -415,6 +415,77 @@ async def test_setpoint_thermostat( client.async_send_command_no_wait.reset_mock() +async def test_thermostat_heatit_z_trm6( + hass: HomeAssistant, client, climate_heatit_z_trm6, integration +) -> None: + """Test a heatit Z-TRM6 entity.""" + node = climate_heatit_z_trm6 + state = hass.states.get(CLIMATE_FLOOR_THERMOSTAT_ENTITY) + + assert state + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_HVAC_MODES] == [ + HVACMode.OFF, + HVACMode.HEAT, + HVACMode.COOL, + ] + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 22.5 + assert state.attributes[ATTR_TEMPERATURE] == 19 + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] + == ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ) + assert state.attributes[ATTR_MIN_TEMP] == 5 + assert state.attributes[ATTR_MAX_TEMP] == 40 + + # Try switching to external sensor (not connected so defaults to 0) + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 101, + "args": { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 2, + "propertyName": "Sensor mode", + "newValue": 4, + "prevValue": 2, + }, + }, + ) + node.receive_event(event) + state = hass.states.get(CLIMATE_FLOOR_THERMOSTAT_ENTITY) + assert state + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 0 + + # Try switching to floor sensor + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 101, + "args": { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 2, + "propertyName": "Sensor mode", + "newValue": 0, + "prevValue": 4, + }, + }, + ) + node.receive_event(event) + state = hass.states.get(CLIMATE_FLOOR_THERMOSTAT_ENTITY) + assert state + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.9 + + async def test_thermostat_heatit_z_trm3_no_value( hass: HomeAssistant, client, climate_heatit_z_trm3_no_value, integration ) -> None: diff --git a/tests/components/zwave_js/test_device_trigger.py b/tests/components/zwave_js/test_device_trigger.py index ba0bbbe087d..f9615c84e1d 100644 --- a/tests/components/zwave_js/test_device_trigger.py +++ b/tests/components/zwave_js/test_device_trigger.py @@ -158,15 +158,13 @@ async def test_if_notification_notification_fires( node.receive_event(event) await hass.async_block_till_done() assert len(calls) == 2 - assert calls[0].data[ - "some" - ] == "event.notification.notification - device - zwave_js_notification - {}".format( - CommandClass.NOTIFICATION + assert ( + calls[0].data["some"] + == f"event.notification.notification - device - zwave_js_notification - {CommandClass.NOTIFICATION}" ) - assert calls[1].data[ - "some" - ] == "event.notification.notification2 - device - zwave_js_notification - {}".format( - CommandClass.NOTIFICATION + assert ( + calls[1].data["some"] + == f"event.notification.notification2 - device - zwave_js_notification - {CommandClass.NOTIFICATION}" ) @@ -288,15 +286,13 @@ async def test_if_entry_control_notification_fires( node.receive_event(event) await hass.async_block_till_done() assert len(calls) == 2 - assert calls[0].data[ - "some" - ] == "event.notification.notification - device - zwave_js_notification - {}".format( - CommandClass.ENTRY_CONTROL + assert ( + calls[0].data["some"] + == f"event.notification.notification - device - zwave_js_notification - {CommandClass.ENTRY_CONTROL}" ) - assert calls[1].data[ - "some" - ] == "event.notification.notification2 - device - zwave_js_notification - {}".format( - CommandClass.ENTRY_CONTROL + assert ( + calls[1].data["some"] + == f"event.notification.notification2 - device - zwave_js_notification - {CommandClass.ENTRY_CONTROL}" ) @@ -705,15 +701,13 @@ async def test_if_basic_value_notification_fires( node.receive_event(event) await hass.async_block_till_done() assert len(calls) == 2 - assert calls[0].data[ - "some" - ] == "event.value_notification.basic - device - zwave_js_value_notification - {}".format( - CommandClass.BASIC + assert ( + calls[0].data["some"] + == f"event.value_notification.basic - device - zwave_js_value_notification - {CommandClass.BASIC}" ) - assert calls[1].data[ - "some" - ] == "event.value_notification.basic2 - device - zwave_js_value_notification - {}".format( - CommandClass.BASIC + assert ( + calls[1].data["some"] + == f"event.value_notification.basic2 - device - zwave_js_value_notification - {CommandClass.BASIC}" ) @@ -888,15 +882,13 @@ async def test_if_central_scene_value_notification_fires( node.receive_event(event) await hass.async_block_till_done() assert len(calls) == 2 - assert calls[0].data[ - "some" - ] == "event.value_notification.central_scene - device - zwave_js_value_notification - {}".format( - CommandClass.CENTRAL_SCENE + assert ( + calls[0].data["some"] + == f"event.value_notification.central_scene - device - zwave_js_value_notification - {CommandClass.CENTRAL_SCENE}" ) - assert calls[1].data[ - "some" - ] == "event.value_notification.central_scene2 - device - zwave_js_value_notification - {}".format( - CommandClass.CENTRAL_SCENE + assert ( + calls[1].data["some"] + == f"event.value_notification.central_scene2 - device - zwave_js_value_notification - {CommandClass.CENTRAL_SCENE}" ) @@ -1064,15 +1056,13 @@ async def test_if_scene_activation_value_notification_fires( node.receive_event(event) await hass.async_block_till_done() assert len(calls) == 2 - assert calls[0].data[ - "some" - ] == "event.value_notification.scene_activation - device - zwave_js_value_notification - {}".format( - CommandClass.SCENE_ACTIVATION + assert ( + calls[0].data["some"] + == f"event.value_notification.scene_activation - device - zwave_js_value_notification - {CommandClass.SCENE_ACTIVATION}" ) - assert calls[1].data[ - "some" - ] == "event.value_notification.scene_activation2 - device - zwave_js_value_notification - {}".format( - CommandClass.SCENE_ACTIVATION + assert ( + calls[1].data["some"] + == f"event.value_notification.scene_activation2 - device - zwave_js_value_notification - {CommandClass.SCENE_ACTIVATION}" ) diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index cbaa27c2a91..569e36d3b5c 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -87,6 +87,7 @@ async def test_lock_popp_electric_strike_lock_control( hass.states.get("binary_sensor.node_62_the_current_status_of_the_door") is not None ) + assert hass.states.get("select.node_62_current_lock_mode") is not None async def test_fortrez_ssa3_siren( diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index 92141eec3ff..c26a5366d37 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -536,13 +536,14 @@ async def test_inovelli_lzw36( assert args["value"] == 1 client.async_send_command.reset_mock() - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await hass.services.async_call( "fan", "turn_on", {"entity_id": entity_id, "preset_mode": "wheeze"}, blocking=True, ) + assert exc.value.translation_key == "not_valid_preset_mode" assert len(client.async_send_command.call_args_list) == 0 @@ -675,13 +676,14 @@ async def test_thermostat_fan( client.async_send_command.reset_mock() # Test setting unknown preset mode - with pytest.raises(ValueError): + with pytest.raises(NotValidPresetModeError) as exc: await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "Turbo"}, blocking=True, ) + assert exc.value.translation_key == "not_valid_preset_mode" client.async_send_command.reset_mock() diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index c57e3b1f868..bf015a70676 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -967,7 +967,7 @@ async def test_removed_device( # Check how many entities there are ent_reg = er.async_get(hass) entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id) - assert len(entity_entries) == 92 + assert len(entity_entries) == 93 # Remove a node and reload the entry old_node = driver.controller.nodes.pop(13) @@ -979,7 +979,7 @@ async def test_removed_device( device_entries = dr.async_entries_for_config_entry(dev_reg, integration.entry_id) assert len(device_entries) == 2 entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id) - assert len(entity_entries) == 61 + assert len(entity_entries) == 62 assert ( dev_reg.async_get_device(identifiers={get_device_id(driver, old_node)}) is None ) diff --git a/tests/components/zwave_js/test_lock.py b/tests/components/zwave_js/test_lock.py index 5a5711d9dad..2213e9cf069 100644 --- a/tests/components/zwave_js/test_lock.py +++ b/tests/components/zwave_js/test_lock.py @@ -15,10 +15,15 @@ from homeassistant.components.lock import ( SERVICE_LOCK, SERVICE_UNLOCK, ) -from homeassistant.components.zwave_js.const import DOMAIN as ZWAVE_JS_DOMAIN +from homeassistant.components.zwave_js.const import ( + ATTR_LOCK_TIMEOUT, + ATTR_OPERATION_TYPE, + DOMAIN as ZWAVE_JS_DOMAIN, +) from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.components.zwave_js.lock import ( SERVICE_CLEAR_LOCK_USERCODE, + SERVICE_SET_LOCK_CONFIGURATION, SERVICE_SET_LOCK_USERCODE, ) from homeassistant.const import ( @@ -35,7 +40,11 @@ from .common import SCHLAGE_BE469_LOCK_ENTITY, replace_value_of_zwave_value async def test_door_lock( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + client, + lock_schlage_be469, + integration, + caplog: pytest.LogCaptureFixture, ) -> None: """Test a lock entity with door lock command class.""" node = lock_schlage_be469 @@ -158,6 +167,96 @@ async def test_door_lock( client.async_send_command.reset_mock() + # Test set configuration + client.async_send_command.return_value = { + "response": {"status": 1, "remainingDuration": "default"} + } + caplog.clear() + await hass.services.async_call( + ZWAVE_JS_DOMAIN, + SERVICE_SET_LOCK_CONFIGURATION, + { + ATTR_ENTITY_ID: SCHLAGE_BE469_LOCK_ENTITY, + ATTR_OPERATION_TYPE: "timed", + ATTR_LOCK_TIMEOUT: 1, + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "endpoint.invoke_cc_api" + assert args["nodeId"] == 20 + assert args["endpoint"] == 0 + assert args["args"] == [ + { + "insideHandlesCanOpenDoorConfiguration": [True, True, True, True], + "operationType": 2, + "outsideHandlesCanOpenDoorConfiguration": [True, True, True, True], + } + ] + assert args["commandClass"] == 98 + assert args["methodName"] == "setConfiguration" + assert "Result status" in caplog.text + assert "remaining duration" in caplog.text + assert "setting lock configuration" in caplog.text + + client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() + caplog.clear() + + # Put node to sleep and validate that we don't wait for a return or log anything + event = Event( + "sleep", + { + "source": "node", + "event": "sleep", + "nodeId": node.node_id, + }, + ) + node.receive_event(event) + + await hass.services.async_call( + ZWAVE_JS_DOMAIN, + SERVICE_SET_LOCK_CONFIGURATION, + { + ATTR_ENTITY_ID: SCHLAGE_BE469_LOCK_ENTITY, + ATTR_OPERATION_TYPE: "timed", + ATTR_LOCK_TIMEOUT: 1, + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 0 + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "endpoint.invoke_cc_api" + assert args["nodeId"] == 20 + assert args["endpoint"] == 0 + assert args["args"] == [ + { + "insideHandlesCanOpenDoorConfiguration": [True, True, True, True], + "operationType": 2, + "outsideHandlesCanOpenDoorConfiguration": [True, True, True, True], + } + ] + assert args["commandClass"] == 98 + assert args["methodName"] == "setConfiguration" + assert "Result status" not in caplog.text + assert "remaining duration" not in caplog.text + assert "setting lock configuration" not in caplog.text + + # Mark node as alive + event = Event( + "alive", + { + "source": "node", + "event": "alive", + "nodeId": node.node_id, + }, + ) + node.receive_event(event) + client.async_send_command.side_effect = FailedZWaveCommand("test", 1, "test") # Test set usercode service error handling with pytest.raises(HomeAssistantError): diff --git a/tests/components/zwave_js/test_select.py b/tests/components/zwave_js/test_select.py index c63f0c429fd..1cbdb8799f3 100644 --- a/tests/components/zwave_js/test_select.py +++ b/tests/components/zwave_js/test_select.py @@ -320,3 +320,30 @@ async def test_config_parameter_select( state = hass.states.get(select_entity_id) assert state assert state.state == "Normal" + + +async def test_lock_popp_electric_strike_lock_control_select( + hass: HomeAssistant, client, lock_popp_electric_strike_lock_control, integration +) -> None: + """Test that the Popp Electric Strike Lock Control select entity.""" + LOCK_SELECT_ENTITY = "select.node_62_current_lock_mode" + state = hass.states.get(LOCK_SELECT_ENTITY) + assert state + assert state.state == "Unsecured" + await hass.services.async_call( + "select", + "select_option", + {"entity_id": LOCK_SELECT_ENTITY, "option": "UnsecuredWithTimeout"}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == lock_popp_electric_strike_lock_control.node_id + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 98, + "property": "targetMode", + } + assert args["value"] == 1 diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index ccbe956fbe5..8697dad2e7b 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch import pytest import voluptuous as vol from zwave_js_server.exceptions import FailedZWaveCommand +from zwave_js_server.model.value import SetConfigParameterResult from homeassistant.components.group import Group from homeassistant.components.zwave_js.const import ( @@ -14,18 +15,23 @@ from homeassistant.components.zwave_js.const import ( ATTR_CONFIG_VALUE, ATTR_ENDPOINT, ATTR_METHOD_NAME, + ATTR_NOTIFICATION_EVENT, + ATTR_NOTIFICATION_TYPE, ATTR_OPTIONS, ATTR_PARAMETERS, ATTR_PROPERTY, ATTR_PROPERTY_KEY, ATTR_REFRESH_ALL_VALUES, ATTR_VALUE, + ATTR_VALUE_FORMAT, + ATTR_VALUE_SIZE, ATTR_WAIT_FOR_RESULT, DOMAIN, SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS, SERVICE_INVOKE_CC_API, SERVICE_MULTICAST_SET_VALUE, SERVICE_PING, + SERVICE_REFRESH_NOTIFICATIONS, SERVICE_REFRESH_VALUE, SERVICE_SET_CONFIG_PARAMETER, SERVICE_SET_VALUE, @@ -53,7 +59,12 @@ from tests.common import MockConfigEntry async def test_set_config_parameter( - hass: HomeAssistant, client, multisensor_6, integration + hass: HomeAssistant, + client, + multisensor_6, + aeotec_zw164_siren, + integration, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the set_config_parameter service.""" dev_reg = async_get_dev_reg(hass) @@ -222,9 +233,75 @@ async def test_set_config_parameter( client.async_send_command_no_wait.reset_mock() + # Test setting parameter by value_size + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: AIR_TEMPERATURE_SENSOR, + ATTR_CONFIG_PARAMETER: 2, + ATTR_VALUE_SIZE: 2, + ATTR_VALUE_FORMAT: 1, + ATTR_CONFIG_VALUE: 1, + }, + blocking=True, + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "endpoint.set_raw_config_parameter_value" + assert args["nodeId"] == 52 + assert args["endpoint"] == 0 + options = args["options"] + assert options["parameter"] == 2 + assert options["value"] == 1 + assert options["valueSize"] == 2 + assert options["valueFormat"] == 1 + + client.async_send_command_no_wait.reset_mock() + + # Test setting parameter when one node has endpoint and other doesn't + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: [AIR_TEMPERATURE_SENSOR, "siren.indoor_siren_6_tone_id"], + ATTR_ENDPOINT: 1, + ATTR_CONFIG_PARAMETER: 32, + ATTR_VALUE_SIZE: 2, + ATTR_VALUE_FORMAT: 1, + ATTR_CONFIG_VALUE: 1, + }, + blocking=True, + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 0 + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "endpoint.set_raw_config_parameter_value" + assert args["nodeId"] == 2 + assert args["endpoint"] == 1 + options = args["options"] + assert options["parameter"] == 32 + assert options["value"] == 1 + assert options["valueSize"] == 2 + assert options["valueFormat"] == 1 + + client.async_send_command_no_wait.reset_mock() + client.async_send_command.reset_mock() + # Test groups get expanded assert await async_setup_component(hass, "group", {}) - await Group.async_create_group(hass, "test", [AIR_TEMPERATURE_SENSOR]) + await Group.async_create_group( + hass, + "test", + created_by_service=False, + entity_ids=[AIR_TEMPERATURE_SENSOR], + icon=None, + mode=None, + object_id=None, + order=None, + ) await hass.services.async_call( DOMAIN, SERVICE_SET_CONFIG_PARAMETER, @@ -284,6 +361,54 @@ async def test_set_config_parameter( config_entry=non_zwave_js_config_entry, ) + # Test unknown endpoint throws error when None are remaining + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: AIR_TEMPERATURE_SENSOR, + ATTR_ENDPOINT: 5, + ATTR_CONFIG_PARAMETER: 2, + ATTR_VALUE_SIZE: 2, + ATTR_VALUE_FORMAT: 1, + ATTR_CONFIG_VALUE: 1, + }, + blocking=True, + ) + + # Test that we can't include bitmask and value size and value format + with pytest.raises(vol.Invalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: AIR_TEMPERATURE_SENSOR, + ATTR_CONFIG_PARAMETER: 102, + ATTR_CONFIG_PARAMETER_BITMASK: 1, + ATTR_CONFIG_VALUE: "Fahrenheit", + ATTR_VALUE_FORMAT: 1, + ATTR_VALUE_SIZE: 2, + }, + blocking=True, + ) + + # Test that value size must be 1, 2, or 4 (not 3) + with pytest.raises(vol.Invalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: AIR_TEMPERATURE_SENSOR, + ATTR_CONFIG_PARAMETER: 102, + ATTR_CONFIG_PARAMETER_BITMASK: 1, + ATTR_CONFIG_VALUE: "Fahrenheit", + ATTR_VALUE_FORMAT: 1, + ATTR_VALUE_SIZE: 3, + }, + blocking=True, + ) + # Test that a Z-Wave JS device with an invalid node ID, non Z-Wave JS entity, # non Z-Wave JS device, invalid device_id, and invalid node_id gets filtered out. await hass.services.async_call( @@ -364,6 +489,75 @@ async def test_set_config_parameter( blocking=True, ) + client.async_send_command_no_wait.reset_mock() + client.async_send_command.reset_mock() + + caplog.clear() + + config_value = aeotec_zw164_siren.values["2-112-0-32"] + cmd_result = SetConfigParameterResult("accepted", {"status": 255}) + + # Test accepted return + with patch( + "homeassistant.components.zwave_js.services.Endpoint.async_set_raw_config_parameter_value", + return_value=(config_value, cmd_result), + ) as mock_set_raw_config_parameter_value: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: ["siren.indoor_siren_6_tone_id"], + ATTR_ENDPOINT: 0, + ATTR_CONFIG_PARAMETER: 32, + ATTR_VALUE_SIZE: 2, + ATTR_VALUE_FORMAT: 1, + ATTR_CONFIG_VALUE: 1, + }, + blocking=True, + ) + assert len(mock_set_raw_config_parameter_value.call_args_list) == 1 + assert mock_set_raw_config_parameter_value.call_args[0][0] == 1 + assert mock_set_raw_config_parameter_value.call_args[0][1] == 32 + assert mock_set_raw_config_parameter_value.call_args[1] == { + "property_key": None, + "value_size": 2, + "value_format": 1, + } + + assert "Set configuration parameter" in caplog.text + caplog.clear() + + # Test queued return + cmd_result.status = "queued" + with patch( + "homeassistant.components.zwave_js.services.Endpoint.async_set_raw_config_parameter_value", + return_value=(config_value, cmd_result), + ) as mock_set_raw_config_parameter_value: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: ["siren.indoor_siren_6_tone_id"], + ATTR_ENDPOINT: 0, + ATTR_CONFIG_PARAMETER: 32, + ATTR_VALUE_SIZE: 2, + ATTR_VALUE_FORMAT: 1, + ATTR_CONFIG_VALUE: 1, + }, + blocking=True, + ) + assert len(mock_set_raw_config_parameter_value.call_args_list) == 1 + assert mock_set_raw_config_parameter_value.call_args[0][0] == 1 + assert mock_set_raw_config_parameter_value.call_args[0][1] == 32 + assert mock_set_raw_config_parameter_value.call_args[1] == { + "property_key": None, + "value_size": 2, + "value_format": 1, + } + + assert "Added command to queue" in caplog.text + caplog.clear() + async def test_set_config_parameter_gather( hass: HomeAssistant, @@ -594,7 +788,16 @@ async def test_bulk_set_config_parameters( # Test groups get expanded assert await async_setup_component(hass, "group", {}) - await Group.async_create_group(hass, "test", [AIR_TEMPERATURE_SENSOR]) + await Group.async_create_group( + hass, + "test", + created_by_service=False, + entity_ids=[AIR_TEMPERATURE_SENSOR], + icon=None, + mode=None, + object_id=None, + order=None, + ) await hass.services.async_call( DOMAIN, SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS, @@ -728,7 +931,16 @@ async def test_refresh_value( # Test groups get expanded assert await async_setup_component(hass, "group", {}) - await Group.async_create_group(hass, "test", [CLIMATE_RADIO_THERMOSTAT_ENTITY]) + await Group.async_create_group( + hass, + "test", + created_by_service=False, + entity_ids=[CLIMATE_RADIO_THERMOSTAT_ENTITY], + icon=None, + mode=None, + object_id=None, + order=None, + ) client.async_send_command.return_value = {"result": 2} await hass.services.async_call( DOMAIN, @@ -848,7 +1060,16 @@ async def test_set_value( # Test groups get expanded assert await async_setup_component(hass, "group", {}) - await Group.async_create_group(hass, "test", [CLIMATE_DANFOSS_LC13_ENTITY]) + await Group.async_create_group( + hass, + "test", + created_by_service=False, + entity_ids=[CLIMATE_DANFOSS_LC13_ENTITY], + icon=None, + mode=None, + object_id=None, + order=None, + ) await hass.services.async_call( DOMAIN, SERVICE_SET_VALUE, @@ -1150,7 +1371,14 @@ async def test_multicast_set_value( # Test groups get expanded for multicast call assert await async_setup_component(hass, "group", {}) await Group.async_create_group( - hass, "test", [CLIMATE_DANFOSS_LC13_ENTITY, CLIMATE_EUROTRONICS_SPIRIT_Z_ENTITY] + hass, + "test", + created_by_service=False, + entity_ids=[CLIMATE_DANFOSS_LC13_ENTITY, CLIMATE_EUROTRONICS_SPIRIT_Z_ENTITY], + icon=None, + mode=None, + object_id=None, + order=None, ) await hass.services.async_call( DOMAIN, @@ -1516,7 +1744,14 @@ async def test_ping( # Test groups get expanded for multicast call assert await async_setup_component(hass, "group", {}) await Group.async_create_group( - hass, "test", [CLIMATE_DANFOSS_LC13_ENTITY, CLIMATE_RADIO_THERMOSTAT_ENTITY] + hass, + "test", + created_by_service=False, + entity_ids=[CLIMATE_DANFOSS_LC13_ENTITY, CLIMATE_RADIO_THERMOSTAT_ENTITY], + icon=None, + mode=None, + object_id=None, + order=None, ) await hass.services.async_call( DOMAIN, @@ -1727,3 +1962,97 @@ async def test_invoke_cc_api( client.async_send_command.reset_mock() client.async_send_command_no_wait.reset_mock() + + +async def test_refresh_notifications( + hass: HomeAssistant, client, zen_31, multisensor_6, integration +) -> None: + """Test refresh_notifications service.""" + dev_reg = async_get_dev_reg(hass) + zen_31_device = dev_reg.async_get_device( + identifiers={get_device_id(client.driver, zen_31)} + ) + assert zen_31_device + multisensor_6_device = dev_reg.async_get_device( + identifiers={get_device_id(client.driver, multisensor_6)} + ) + assert multisensor_6_device + + area_reg = async_get_area_reg(hass) + area = area_reg.async_get_or_create("test") + dev_reg.async_update_device(zen_31_device.id, area_id=area.id) + + # Test successful refresh_notifications call + client.async_send_command.return_value = {"response": True} + client.async_send_command_no_wait.return_value = {"response": True} + + await hass.services.async_call( + DOMAIN, + SERVICE_REFRESH_NOTIFICATIONS, + { + ATTR_AREA_ID: area.id, + ATTR_DEVICE_ID: [zen_31_device.id, multisensor_6_device.id], + ATTR_NOTIFICATION_TYPE: 1, + ATTR_NOTIFICATION_EVENT: 2, + }, + blocking=True, + ) + await hass.async_block_till_done() + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "endpoint.invoke_cc_api" + assert args["commandClass"] == 113 + assert args["endpoint"] == 0 + assert args["methodName"] == "get" + assert args["args"] == [{"notificationType": 1, "notificationEvent": 2}] + assert args["nodeId"] == zen_31.node_id + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "endpoint.invoke_cc_api" + assert args["commandClass"] == 113 + assert args["endpoint"] == 0 + assert args["methodName"] == "get" + assert args["args"] == [{"notificationType": 1, "notificationEvent": 2}] + assert args["nodeId"] == multisensor_6.node_id + + client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() + + # Test failed refresh_notifications call on one node. We return the error on + # the first node in the call to make sure that gather works as expected + client.async_send_command.return_value = {"response": True} + client.async_send_command_no_wait.side_effect = FailedZWaveCommand( + "test", 12, "test" + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_REFRESH_NOTIFICATIONS, + { + ATTR_DEVICE_ID: [multisensor_6_device.id, zen_31_device.id], + ATTR_NOTIFICATION_TYPE: 1, + }, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "endpoint.invoke_cc_api" + assert args["commandClass"] == 113 + assert args["endpoint"] == 0 + assert args["methodName"] == "get" + assert args["args"] == [{"notificationType": 1}] + assert args["nodeId"] == zen_31.node_id + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "endpoint.invoke_cc_api" + assert args["commandClass"] == 113 + assert args["endpoint"] == 0 + assert args["methodName"] == "get" + assert args["args"] == [{"notificationType": 1}] + assert args["nodeId"] == multisensor_6.node_id + + client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index 46dca7a35ec..9e17f25c708 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -694,6 +694,42 @@ async def test_update_entity_partial_restore_data( assert state.state == STATE_UNKNOWN +async def test_update_entity_partial_restore_data_2( + hass: HomeAssistant, + client, + climate_radio_thermostat_ct100_plus_different_endpoints, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test second scenario where update entity has partial restore data.""" + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + UPDATE_ENTITY, + STATE_ON, + { + ATTR_INSTALLED_VERSION: "10.7", + ATTR_LATEST_VERSION: "10.8", + ATTR_SKIPPED_VERSION: None, + }, + ), + {"latest_version_firmware": None}, + ) + ], + ) + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY) + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes[ATTR_SKIPPED_VERSION] is None + assert state.attributes[ATTR_LATEST_VERSION] is None + + async def test_update_entity_full_restore_data_skipped_version( hass: HomeAssistant, client, diff --git a/tests/conftest.py b/tests/conftest.py index 09ad70bfcf1..4050c1cdb6a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1145,13 +1145,19 @@ def mock_zeroconf() -> Generator[None, None, None]: @pytest.fixture def mock_async_zeroconf(mock_zeroconf: None) -> Generator[None, None, None]: """Mock AsyncZeroconf.""" - from zeroconf import DNSCache # pylint: disable=import-outside-toplevel + from zeroconf import DNSCache, Zeroconf # pylint: disable=import-outside-toplevel + from zeroconf.asyncio import ( # pylint: disable=import-outside-toplevel + AsyncZeroconf, + ) - with patch("homeassistant.components.zeroconf.HaAsyncZeroconf") as mock_aiozc: + with patch( + "homeassistant.components.zeroconf.HaAsyncZeroconf", spec=AsyncZeroconf + ) as mock_aiozc: zc = mock_aiozc.return_value zc.async_unregister_service = AsyncMock() zc.async_register_service = AsyncMock() zc.async_update_service = AsyncMock() + zc.zeroconf = Mock(spec=Zeroconf) zc.zeroconf.async_wait_for_start = AsyncMock() # DNSCache has strong Cython type checks, and MagicMock does not work # so we must mock the class directly diff --git a/tests/fixtures/core/config/component_validation/basic/configuration.yaml b/tests/fixtures/core/config/component_validation/basic/configuration.yaml new file mode 100644 index 00000000000..9c3d1eb190b --- /dev/null +++ b/tests/fixtures/core/config/component_validation/basic/configuration.yaml @@ -0,0 +1,58 @@ +iot_domain: + # This is correct and should not generate errors + - platform: non_adr_0007 + option1: abc + # This violates the iot_domain platform schema (platform missing) + - paltfrom: non_adr_0007 + # This violates the non_adr_0007.iot_domain platform schema (option1 wrong type) + - platform: non_adr_0007 + option1: 123 + # This violates the non_adr_0007.iot_domain platform schema (no_such_option does not exist) + - platform: non_adr_0007 + no_such_option: abc + option1: abc + # This violates the non_adr_0007.iot_domain platform schema: + # - no_such_option does not exist + # - option1 is missing + # - option2 is wrong type + - platform: non_adr_0007 + no_such_option: abc + option2: 123 + +# This is correct and should not generate errors +adr_0007_1: + host: blah.com + +# Host is missing +adr_0007_2: + +# Port is wrong type +adr_0007_3: + host: blah.com + port: foo + +# no_such_option does not exist +adr_0007_4: + host: blah.com + no_such_option: foo + +# Multiple errors: +# - host is missing +# - no_such_option does not exist +# - port is wrong type +adr_0007_5: + no_such_option: foo + port: foo + +# This is correct and should not generate errors +custom_validator_ok_1: + host: blah.com + +# Host is missing +custom_validator_ok_2: + +# This always raises HomeAssistantError +custom_validator_bad_1: + +# This always raises ValueError +custom_validator_bad_2: diff --git a/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml b/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml new file mode 100644 index 00000000000..5744e3005fa --- /dev/null +++ b/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml @@ -0,0 +1,10 @@ +iot_domain: !include integrations/iot_domain.yaml +adr_0007_1: !include integrations/adr_0007_1.yaml +adr_0007_2: !include integrations/adr_0007_2.yaml +adr_0007_3: !include integrations/adr_0007_3.yaml +adr_0007_4: !include integrations/adr_0007_4.yaml +adr_0007_5: !include integrations/adr_0007_5.yaml +custom_validator_ok_1: !include integrations/custom_validator_ok_1.yaml +custom_validator_ok_2: !include integrations/custom_validator_ok_2.yaml +custom_validator_bad_1: !include integrations/custom_validator_bad_1.yaml +custom_validator_bad_2: !include integrations/custom_validator_bad_2.yaml diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_1.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_1.yaml new file mode 100644 index 00000000000..d246d73c257 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_1.yaml @@ -0,0 +1,2 @@ +# This is correct and should not generate errors +host: blah.com diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_2.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_2.yaml new file mode 100644 index 00000000000..8b592b01e2d --- /dev/null +++ b/tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_2.yaml @@ -0,0 +1 @@ +# Host is missing diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_3.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_3.yaml new file mode 100644 index 00000000000..c3b2edb3f94 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_3.yaml @@ -0,0 +1,3 @@ +# Port is wrong type +host: blah.com +port: foo diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_4.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_4.yaml new file mode 100644 index 00000000000..e8dcd8f4017 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_4.yaml @@ -0,0 +1,3 @@ +# no_such_option does not exist +host: blah.com +no_such_option: foo diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_5.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_5.yaml new file mode 100644 index 00000000000..0cda3d04a55 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/basic_include/integrations/adr_0007_5.yaml @@ -0,0 +1,6 @@ +# Multiple errors: +# - host is missing +# - no_such_option does not exist +# - port is wrong type +no_such_option: foo +port: foo diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_bad_1.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_bad_1.yaml new file mode 100644 index 00000000000..12d6d869f35 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_bad_1.yaml @@ -0,0 +1 @@ +# This always raises HomeAssistantError diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_bad_2.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_bad_2.yaml new file mode 100644 index 00000000000..7af4b20c016 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_bad_2.yaml @@ -0,0 +1 @@ +# This always raises ValueError diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_ok_1.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_ok_1.yaml new file mode 100644 index 00000000000..d246d73c257 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_ok_1.yaml @@ -0,0 +1,2 @@ +# This is correct and should not generate errors +host: blah.com diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_ok_2.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_ok_2.yaml new file mode 100644 index 00000000000..8b592b01e2d --- /dev/null +++ b/tests/fixtures/core/config/component_validation/basic_include/integrations/custom_validator_ok_2.yaml @@ -0,0 +1 @@ +# Host is missing diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml new file mode 100644 index 00000000000..dd592194f1a --- /dev/null +++ b/tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain.yaml @@ -0,0 +1,19 @@ +# This is correct and should not generate errors +- platform: non_adr_0007 + option1: abc +# This violates the iot_domain platform schema (platform missing) +- paltfrom: non_adr_0007 +# This violates the non_adr_0007.iot_domain platform schema (option1 wrong type) +- platform: non_adr_0007 + option1: 123 +# This violates the non_adr_0007.iot_domain platform schema (no_such_option does not exist) +- platform: non_adr_0007 + no_such_option: abc + option1: abc +# This violates the non_adr_0007.iot_domain platform schema: +# - no_such_option does not exist +# - option1 is missing +# - option2 is wrong type +- platform: non_adr_0007 + no_such_option: abc + option2: 123 diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/configuration.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/configuration.yaml new file mode 100644 index 00000000000..bb0f052a39a --- /dev/null +++ b/tests/fixtures/core/config/component_validation/include_dir_list/configuration.yaml @@ -0,0 +1 @@ +iot_domain: !include_dir_list iot_domain diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_1.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_1.yaml new file mode 100644 index 00000000000..b17f6106208 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_1.yaml @@ -0,0 +1,3 @@ +# This is correct and should not generate errors +platform: non_adr_0007 +option1: abc diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml new file mode 100644 index 00000000000..f6c3219741e --- /dev/null +++ b/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_2.yaml @@ -0,0 +1,2 @@ +# This violates the iot_domain platform schema (platform missing) +paltfrom: non_adr_0007 diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml new file mode 100644 index 00000000000..2265e8c2f07 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_3.yaml @@ -0,0 +1,3 @@ +# This violates the non_adr_0007.iot_domain platform schema (option1 wrong type) +platform: non_adr_0007 +option1: 123 diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_4.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_4.yaml new file mode 100644 index 00000000000..53f220472e2 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_4.yaml @@ -0,0 +1,4 @@ +# This violates the non_adr_0007.iot_domain platform schema (no_such_option does not exist) +platform: non_adr_0007 +no_such_option: abc +option1: abc diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml new file mode 100644 index 00000000000..b0fec6d5046 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/include_dir_list/iot_domain/iot_domain_5.yaml @@ -0,0 +1,7 @@ +# This violates the non_adr_0007.iot_domain platform schema: +# - no_such_option does not exist +# - option1 is missing +# - option2 is wrong type +platform: non_adr_0007 +no_such_option: abc +option2: 123 diff --git a/tests/fixtures/core/config/component_validation/include_dir_merge_list/configuration.yaml b/tests/fixtures/core/config/component_validation/include_dir_merge_list/configuration.yaml new file mode 100644 index 00000000000..e0c03e9f445 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/include_dir_merge_list/configuration.yaml @@ -0,0 +1 @@ +iot_domain: !include_dir_merge_list iot_domain diff --git a/tests/fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_1.yaml b/tests/fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_1.yaml new file mode 100644 index 00000000000..172f96e2da2 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_1.yaml @@ -0,0 +1,5 @@ +# This is correct and should not generate errors +- platform: non_adr_0007 + option1: abc +# This violates the iot_domain platform schema (platform missing) +- paltfrom: non_adr_0007 diff --git a/tests/fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml b/tests/fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml new file mode 100644 index 00000000000..f8ef2b5643b --- /dev/null +++ b/tests/fixtures/core/config/component_validation/include_dir_merge_list/iot_domain/iot_domain_2.yaml @@ -0,0 +1,14 @@ +# This violates the non_adr_0007.iot_domain platform schema (option1 wrong type) +- platform: non_adr_0007 + option1: 123 +# This violates the non_adr_0007.iot_domain platform schema (no_such_option does not exist) +- platform: non_adr_0007 + no_such_option: abc + option1: abc +# This violates the non_adr_0007.iot_domain platform schema: +# - no_such_option does not exist +# - option1 is missing +# - option2 is wrong type +- platform: non_adr_0007 + no_such_option: abc + option2: 123 diff --git a/tests/fixtures/core/config/component_validation/packages/configuration.yaml b/tests/fixtures/core/config/component_validation/packages/configuration.yaml new file mode 100644 index 00000000000..b8116b5988e --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages/configuration.yaml @@ -0,0 +1,70 @@ +homeassistant: + packages: + pack_iot_domain_1: + iot_domain: + # This is correct and should not generate errors + - platform: non_adr_0007 + option1: abc + pack_iot_domain_2: + iot_domain: + # This violates the iot_domain platform schema (platform missing) + - paltfrom: non_adr_0007 + pack_iot_domain_3: + iot_domain: + # This violates the non_adr_0007.iot_domain platform schema (option1 wrong type) + - platform: non_adr_0007 + option1: 123 + pack_iot_domain_4: + iot_domain: + # This violates the non_adr_0007.iot_domain platform schema (no_such_option does not exist) + - platform: non_adr_0007 + no_such_option: abc + option1: abc + pack_iot_domain_5: + iot_domain: + # This violates the non_adr_0007.iot_domain platform schema: + # - no_such_option does not exist + # - option1 is missing + # - option2 is wrong type + - platform: non_adr_0007 + no_such_option: abc + option2: 123 + pack_adr_0007_1: + # This is correct and should not generate errors + adr_0007_1: + host: blah.com + pack_adr_0007_2: + # Host is missing + adr_0007_2: + pack_adr_0007_3: + # Port is wrong type + adr_0007_3: + host: blah.com + port: foo + pack_adr_0007_4: + # no_such_option does not exist + adr_0007_4: + host: blah.com + no_such_option: foo + pack_adr_0007_5: + # Multiple errors: + # - host is missing + # - no_such_option does not exist + # - port is wrong type + adr_0007_5: + no_such_option: foo + port: foo + + pack_custom_validator_ok_1: + # This is correct and should not generate errors + custom_validator_ok_1: + host: blah.com + pack_custom_validator_ok_2: + # Host is missing + custom_validator_ok_2: + pack_custom_validator_bad_1: + # This always raises HomeAssistantError + custom_validator_bad_1: + pack_custom_validator_bad_2: + # This always raises ValueError + custom_validator_bad_2: diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/configuration.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/configuration.yaml new file mode 100644 index 00000000000..d3b52e4d49d --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/configuration.yaml @@ -0,0 +1,3 @@ +homeassistant: + # Load packages + packages: !include_dir_named integrations diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_1.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_1.yaml new file mode 100644 index 00000000000..c07a9434f82 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_1.yaml @@ -0,0 +1,3 @@ +# This is correct and should not generate errors +adr_0007_1: + host: blah.com diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_2.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_2.yaml new file mode 100644 index 00000000000..0f96654008e --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_2.yaml @@ -0,0 +1,2 @@ +# Host is missing +adr_0007_2: diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_3.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_3.yaml new file mode 100644 index 00000000000..1ad33e67171 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_3.yaml @@ -0,0 +1,4 @@ +# Port is wrong type +adr_0007_3: + host: blah.com + port: foo diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_4.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_4.yaml new file mode 100644 index 00000000000..b5d4602c683 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_4.yaml @@ -0,0 +1,4 @@ +# no_such_option does not exist +adr_0007_4: + host: blah.com + no_such_option: foo diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml new file mode 100644 index 00000000000..fad2c53d527 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/adr_0007_5.yaml @@ -0,0 +1,7 @@ +# Multiple errors: +# - host is missing +# - no_such_option does not exist +# - port is wrong type +adr_0007_5: + no_such_option: foo + port: foo diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_bad_1.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_bad_1.yaml new file mode 100644 index 00000000000..2e17b766800 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_bad_1.yaml @@ -0,0 +1,2 @@ +# This always raises HomeAssistantError +custom_validator_bad_1: diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_bad_2.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_bad_2.yaml new file mode 100644 index 00000000000..213c3ea03f8 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_bad_2.yaml @@ -0,0 +1,2 @@ +# This always raises ValueError +custom_validator_bad_2: diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_ok_1.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_ok_1.yaml new file mode 100644 index 00000000000..257ff66d10b --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_ok_1.yaml @@ -0,0 +1,3 @@ +# This is correct and should not generate errors +custom_validator_ok_1: + host: blah.com diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_ok_2.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_ok_2.yaml new file mode 100644 index 00000000000..59a240defaf --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/custom_validator_ok_2.yaml @@ -0,0 +1,2 @@ +# Host is missing +custom_validator_ok_2: diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml new file mode 100644 index 00000000000..e137411b0fc --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/iot_domain.yaml @@ -0,0 +1,20 @@ +iot_domain: + # This is correct and should not generate errors + - platform: non_adr_0007 + option1: abc + # This violates the iot_domain platform schema (platform missing) + - paltfrom: non_adr_0007 + # This violates the non_adr_0007.iot_domain platform schema (option1 wrong type) + - platform: non_adr_0007 + option1: 123 + # This violates the non_adr_0007.iot_domain platform schema (no_such_option does not exist) + - platform: non_adr_0007 + no_such_option: abc + option1: abc + # This violates the non_adr_0007.iot_domain platform schema: + # - no_such_option does not exist + # - option1 is missing + # - option2 is wrong type + - platform: non_adr_0007 + no_such_option: abc + option2: 123 diff --git a/tests/fixtures/core/config/package_errors/packages/configuration.yaml b/tests/fixtures/core/config/package_errors/packages/configuration.yaml new file mode 100644 index 00000000000..19ec6e1e983 --- /dev/null +++ b/tests/fixtures/core/config/package_errors/packages/configuration.yaml @@ -0,0 +1,24 @@ +# adr007_1 should be a dict, this will cause a package error +adr_0007_1: + - host: blah.com + +homeassistant: + packages: + pack_1: + # This is correct, but root config is wrong + adr_0007_1: + port: 8080 + pack_2: + # Should not be a list + adr_0007_2: + - host: blah.com + pack_3: + # Host duplicated in pack_4 + adr_0007_3: + host: blah.com + pack_4: + adr_0007_3: + host: blah.com + pack_5: + unknown_integration: + host: blah.com diff --git a/tests/fixtures/core/config/package_errors/packages_include_dir_named/configuration.yaml b/tests/fixtures/core/config/package_errors/packages_include_dir_named/configuration.yaml new file mode 100644 index 00000000000..85ffc610758 --- /dev/null +++ b/tests/fixtures/core/config/package_errors/packages_include_dir_named/configuration.yaml @@ -0,0 +1,7 @@ +# adr007_1 should be a dict, this will cause a package error +adr_0007_1: + - host: blah.com + +homeassistant: + # Load packages + packages: !include_dir_named integrations diff --git a/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_1.yaml b/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_1.yaml new file mode 100644 index 00000000000..09cbdaa1bf8 --- /dev/null +++ b/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_1.yaml @@ -0,0 +1,3 @@ +# This is correct, but root config is wrong +adr_0007_1: + port: 8080 diff --git a/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_2.yaml b/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_2.yaml new file mode 100644 index 00000000000..c1ab9d84c48 --- /dev/null +++ b/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_2.yaml @@ -0,0 +1,3 @@ +# Should not be a list +adr_0007_2: + - host: blah.com diff --git a/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_3_1.yaml b/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_3_1.yaml new file mode 100644 index 00000000000..1b524ae6ec1 --- /dev/null +++ b/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_3_1.yaml @@ -0,0 +1,3 @@ +# Host duplicated in adr_0007_3_2.yaml +adr_0007_3: + host: blah.com diff --git a/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_3_2.yaml b/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_3_2.yaml new file mode 100644 index 00000000000..5e28092d6c0 --- /dev/null +++ b/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/adr_0007_3_2.yaml @@ -0,0 +1,2 @@ +adr_0007_3: + host: blah.com diff --git a/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/unknown_integration.yaml b/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/unknown_integration.yaml new file mode 100644 index 00000000000..d041b77ea29 --- /dev/null +++ b/tests/fixtures/core/config/package_errors/packages_include_dir_named/integrations/unknown_integration.yaml @@ -0,0 +1,3 @@ +# Unknown integration +unknown_integration: + host: blah.com diff --git a/tests/fixtures/core/config/package_exceptions/packages/configuration.yaml b/tests/fixtures/core/config/package_exceptions/packages/configuration.yaml new file mode 100644 index 00000000000..bf2a79c1307 --- /dev/null +++ b/tests/fixtures/core/config/package_exceptions/packages/configuration.yaml @@ -0,0 +1,4 @@ +homeassistant: + packages: + pack_1: + test_domain: diff --git a/tests/fixtures/core/config/package_exceptions/packages_include_dir_named/configuration.yaml b/tests/fixtures/core/config/package_exceptions/packages_include_dir_named/configuration.yaml new file mode 100644 index 00000000000..d3b52e4d49d --- /dev/null +++ b/tests/fixtures/core/config/package_exceptions/packages_include_dir_named/configuration.yaml @@ -0,0 +1,3 @@ +homeassistant: + # Load packages + packages: !include_dir_named integrations diff --git a/tests/fixtures/core/config/package_exceptions/packages_include_dir_named/integrations/unknown_integration.yaml b/tests/fixtures/core/config/package_exceptions/packages_include_dir_named/integrations/unknown_integration.yaml new file mode 100644 index 00000000000..66a70375f70 --- /dev/null +++ b/tests/fixtures/core/config/package_exceptions/packages_include_dir_named/integrations/unknown_integration.yaml @@ -0,0 +1 @@ +test_domain: diff --git a/tests/fixtures/core/config/yaml_errors/basic/configuration.yaml b/tests/fixtures/core/config/yaml_errors/basic/configuration.yaml new file mode 100644 index 00000000000..86292e7ab96 --- /dev/null +++ b/tests/fixtures/core/config/yaml_errors/basic/configuration.yaml @@ -0,0 +1,4 @@ +iot_domain: + # Indentation error + - platform: non_adr_0007 + option1: abc diff --git a/tests/fixtures/core/config/yaml_errors/basic_include/configuration.yaml b/tests/fixtures/core/config/yaml_errors/basic_include/configuration.yaml new file mode 100644 index 00000000000..7b343d41e9a --- /dev/null +++ b/tests/fixtures/core/config/yaml_errors/basic_include/configuration.yaml @@ -0,0 +1 @@ +iot_domain: !include integrations/iot_domain.yaml diff --git a/tests/fixtures/core/config/yaml_errors/basic_include/integrations/iot_domain.yaml b/tests/fixtures/core/config/yaml_errors/basic_include/integrations/iot_domain.yaml new file mode 100644 index 00000000000..4e01fecc74c --- /dev/null +++ b/tests/fixtures/core/config/yaml_errors/basic_include/integrations/iot_domain.yaml @@ -0,0 +1,3 @@ +# Indentation error +- platform: non_adr_0007 + option1: abc diff --git a/tests/fixtures/core/config/yaml_errors/include_dir_list/configuration.yaml b/tests/fixtures/core/config/yaml_errors/include_dir_list/configuration.yaml new file mode 100644 index 00000000000..bb0f052a39a --- /dev/null +++ b/tests/fixtures/core/config/yaml_errors/include_dir_list/configuration.yaml @@ -0,0 +1 @@ +iot_domain: !include_dir_list iot_domain diff --git a/tests/fixtures/core/config/yaml_errors/include_dir_list/iot_domain/iot_domain_1.yaml b/tests/fixtures/core/config/yaml_errors/include_dir_list/iot_domain/iot_domain_1.yaml new file mode 100644 index 00000000000..5c01bd1b3c1 --- /dev/null +++ b/tests/fixtures/core/config/yaml_errors/include_dir_list/iot_domain/iot_domain_1.yaml @@ -0,0 +1,3 @@ +# Indentation error +platform: non_adr_0007 + option1: abc diff --git a/tests/fixtures/core/config/yaml_errors/include_dir_merge_list/configuration.yaml b/tests/fixtures/core/config/yaml_errors/include_dir_merge_list/configuration.yaml new file mode 100644 index 00000000000..e0c03e9f445 --- /dev/null +++ b/tests/fixtures/core/config/yaml_errors/include_dir_merge_list/configuration.yaml @@ -0,0 +1 @@ +iot_domain: !include_dir_merge_list iot_domain diff --git a/tests/fixtures/core/config/yaml_errors/include_dir_merge_list/iot_domain/iot_domain_1.yaml b/tests/fixtures/core/config/yaml_errors/include_dir_merge_list/iot_domain/iot_domain_1.yaml new file mode 100644 index 00000000000..4e01fecc74c --- /dev/null +++ b/tests/fixtures/core/config/yaml_errors/include_dir_merge_list/iot_domain/iot_domain_1.yaml @@ -0,0 +1,3 @@ +# Indentation error +- platform: non_adr_0007 + option1: abc diff --git a/tests/fixtures/core/config/yaml_errors/packages_include_dir_named/configuration.yaml b/tests/fixtures/core/config/yaml_errors/packages_include_dir_named/configuration.yaml new file mode 100644 index 00000000000..d3b52e4d49d --- /dev/null +++ b/tests/fixtures/core/config/yaml_errors/packages_include_dir_named/configuration.yaml @@ -0,0 +1,3 @@ +homeassistant: + # Load packages + packages: !include_dir_named integrations diff --git a/tests/fixtures/core/config/yaml_errors/packages_include_dir_named/integrations/adr_0007_1.yaml b/tests/fixtures/core/config/yaml_errors/packages_include_dir_named/integrations/adr_0007_1.yaml new file mode 100644 index 00000000000..f9f2f6e7319 --- /dev/null +++ b/tests/fixtures/core/config/yaml_errors/packages_include_dir_named/integrations/adr_0007_1.yaml @@ -0,0 +1,4 @@ +# Indentation error +adr_0007_1: + host: blah.com + port: 123 diff --git a/tests/helpers/test_aiohttp_compat.py b/tests/helpers/test_aiohttp_compat.py deleted file mode 100644 index 749984dbc2e..00000000000 --- a/tests/helpers/test_aiohttp_compat.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Test the aiohttp compatibility shim.""" - -import asyncio -from contextlib import suppress - -from aiohttp import client, web, web_protocol, web_server -import pytest - -from homeassistant.helpers.aiohttp_compat import CancelOnDisconnectRequestHandler - - -@pytest.mark.allow_hosts(["127.0.0.1"]) -async def test_handler_cancellation(socket_enabled, unused_tcp_port_factory) -> None: - """Test that handler cancels the request on disconnect. - - From aiohttp tests/test_web_server.py - """ - assert web_protocol.RequestHandler is CancelOnDisconnectRequestHandler - assert web_server.RequestHandler is CancelOnDisconnectRequestHandler - - event = asyncio.Event() - port = unused_tcp_port_factory() - - async def on_request(_: web.Request) -> web.Response: - nonlocal event - try: - await asyncio.sleep(10) - except asyncio.CancelledError: - event.set() - raise - else: - raise web.HTTPInternalServerError() - - app = web.Application() - app.router.add_route("GET", "/", on_request) - - runner = web.AppRunner(app) - await runner.setup() - - site = web.TCPSite(runner, host="127.0.0.1", port=port) - - await site.start() - - try: - async with client.ClientSession( - timeout=client.ClientTimeout(total=0.1) - ) as sess: - with pytest.raises(asyncio.TimeoutError): - await sess.get(f"http://127.0.0.1:{port}/") - - with suppress(asyncio.TimeoutError): - await asyncio.wait_for(event.wait(), timeout=1) - assert event.is_set(), "Request handler hasn't been cancelled" - finally: - await asyncio.gather(runner.shutdown(), site.stop()) diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index a3fd02686ac..b65f09aeaf9 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -2,16 +2,27 @@ import logging from unittest.mock import Mock, patch +import pytest +import voluptuous as vol + from homeassistant.config import YAML_CONFIG_FILE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.check_config import ( CheckConfigError, + HomeAssistantConfig, async_check_ha_config_file, ) import homeassistant.helpers.config_validation as cv from homeassistant.requirements import RequirementsNotFound -from tests.common import MockModule, mock_integration, mock_platform, patch_yaml_files +from tests.common import ( + MockModule, + MockPlatform, + mock_integration, + mock_platform, + patch_yaml_files, +) _LOGGER = logging.getLogger(__name__) @@ -40,6 +51,28 @@ def log_ha_config(conf): _LOGGER.debug("error[%s] = %s", cnt, err) +def _assert_warnings_errors( + res: HomeAssistantConfig, + expected_warnings: list[CheckConfigError], + expected_errors: list[CheckConfigError], +) -> None: + assert len(res.warnings) == len(expected_warnings) + assert len(res.errors) == len(expected_errors) + + expected_warning_str = "" + expected_error_str = "" + + for idx, expected_warning in enumerate(expected_warnings): + assert res.warnings[idx] == expected_warning + expected_warning_str += expected_warning.message + assert res.warning_str == expected_warning_str + + for idx, expected_error in enumerate(expected_errors): + assert res.errors[idx] == expected_error + expected_error_str += expected_error.message + assert res.error_str == expected_error_str + + async def test_bad_core_config(hass: HomeAssistant) -> None: """Test a bad core config setup.""" files = {YAML_CONFIG_FILE: BAD_CORE_CONFIG} @@ -47,13 +80,15 @@ async def test_bad_core_config(hass: HomeAssistant) -> None: res = await async_check_ha_config_file(hass) log_ha_config(res) - assert isinstance(res.errors[0].message, str) - assert res.errors[0].domain == "homeassistant" - assert res.errors[0].config == {"unit_system": "bad"} - - # Only 1 error expected - res.errors.pop(0) - assert not res.errors + error = CheckConfigError( + ( + f"Invalid config for 'homeassistant' at {YAML_CONFIG_FILE}, line 2:" + " not a valid value for dictionary value 'unit_system', got 'bad'" + ), + "homeassistant", + {"unit_system": "bad"}, + ) + _assert_warnings_errors(res, [], [error]) async def test_config_platform_valid(hass: HomeAssistant) -> None: @@ -65,11 +100,11 @@ async def test_config_platform_valid(hass: HomeAssistant) -> None: assert res.keys() == {"homeassistant", "light"} assert res["light"] == [{"platform": "demo"}] - assert not res.errors + _assert_warnings_errors(res, [], []) -async def test_component_platform_not_found(hass: HomeAssistant) -> None: - """Test errors if component or platform not found.""" +async def test_integration_not_found(hass: HomeAssistant) -> None: + """Test errors if integration not found.""" # Make sure they don't exist files = {YAML_CONFIG_FILE: BASE_CONFIG + "beer:"} with patch("os.path.isfile", return_value=True), patch_yaml_files(files): @@ -77,17 +112,14 @@ async def test_component_platform_not_found(hass: HomeAssistant) -> None: log_ha_config(res) assert res.keys() == {"homeassistant"} - assert res.errors[0] == CheckConfigError( + warning = CheckConfigError( "Integration error: beer - Integration 'beer' not found.", None, None ) - - # Only 1 error expected - res.errors.pop(0) - assert not res.errors + _assert_warnings_errors(res, [warning], []) -async def test_component_requirement_not_found(hass: HomeAssistant) -> None: - """Test errors if component with a requirement not found not found.""" +async def test_integrationt_requirement_not_found(hass: HomeAssistant) -> None: + """Test errors if integration with a requirement not found not found.""" # Make sure they don't exist files = {YAML_CONFIG_FILE: BASE_CONFIG + "test_custom_component:"} with patch( @@ -98,7 +130,7 @@ async def test_component_requirement_not_found(hass: HomeAssistant) -> None: log_ha_config(res) assert res.keys() == {"homeassistant"} - assert res.errors[0] == CheckConfigError( + warning = CheckConfigError( ( "Integration error: test_custom_component - Requirements for" " test_custom_component not found: ['any']." @@ -106,14 +138,11 @@ async def test_component_requirement_not_found(hass: HomeAssistant) -> None: None, None, ) - - # Only 1 error expected - res.errors.pop(0) - assert not res.errors + _assert_warnings_errors(res, [warning], []) -async def test_component_not_found_recovery_mode(hass: HomeAssistant) -> None: - """Test no errors if component not found in recovery mode.""" +async def test_integration_not_found_recovery_mode(hass: HomeAssistant) -> None: + """Test no errors if integration not found in recovery mode.""" # Make sure they don't exist files = {YAML_CONFIG_FILE: BASE_CONFIG + "beer:"} hass.config.recovery_mode = True @@ -122,11 +151,11 @@ async def test_component_not_found_recovery_mode(hass: HomeAssistant) -> None: log_ha_config(res) assert res.keys() == {"homeassistant"} - assert not res.errors + _assert_warnings_errors(res, [], []) -async def test_component_not_found_safe_mode(hass: HomeAssistant) -> None: - """Test no errors if component not found in safe mode.""" +async def test_integration_not_found_safe_mode(hass: HomeAssistant) -> None: + """Test no errors if integration not found in safe mode.""" # Make sure they don't exist files = {YAML_CONFIG_FILE: BASE_CONFIG + "beer:"} hass.config.safe_mode = True @@ -135,11 +164,59 @@ async def test_component_not_found_safe_mode(hass: HomeAssistant) -> None: log_ha_config(res) assert res.keys() == {"homeassistant"} - assert not res.errors + _assert_warnings_errors(res, [], []) -async def test_component_platform_not_found_2(hass: HomeAssistant) -> None: - """Test errors if component or platform not found.""" +async def test_integration_import_error(hass: HomeAssistant) -> None: + """Test errors if integration with a requirement not found not found.""" + # Make sure they don't exist + files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:"} + with patch( + "homeassistant.loader.Integration.get_component", + side_effect=ImportError("blablabla"), + ), patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert res.keys() == {"homeassistant"} + warning = CheckConfigError( + "Component error: light - blablabla", + None, + None, + ) + _assert_warnings_errors(res, [warning], []) + + +@pytest.mark.parametrize( + ("integration", "errors", "warnings", "message"), + [ + ("frontend", 1, 0, "'blah' is an invalid option for 'frontend'"), + ("http", 1, 0, "'blah' is an invalid option for 'http'"), + ("logger", 0, 1, "'blah' is an invalid option for 'logger'"), + ], +) +async def test_integration_schema_error( + hass: HomeAssistant, integration: str, errors: int, warnings: int, message: str +) -> None: + """Test schema error in integration.""" + # Make sure they don't exist + files = {YAML_CONFIG_FILE: BASE_CONFIG + f"frontend:\n{integration}:\n blah:"} + hass.config.safe_mode = True + with patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert len(res.errors) == errors + assert len(res.warnings) == warnings + + for err in res.errors: + assert message in err.message + for warn in res.warnings: + assert message in warn.message + + +async def test_platform_not_found(hass: HomeAssistant) -> None: + """Test errors if platform not found.""" # Make sure they don't exist files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:\n platform: beer"} with patch("os.path.isfile", return_value=True), patch_yaml_files(files): @@ -149,17 +226,14 @@ async def test_component_platform_not_found_2(hass: HomeAssistant) -> None: assert res.keys() == {"homeassistant", "light"} assert res["light"] == [] - assert res.errors[0] == CheckConfigError( + warning = CheckConfigError( "Platform error light.beer - Integration 'beer' not found.", None, None ) - - # Only 1 error expected - res.errors.pop(0) - assert not res.errors + _assert_warnings_errors(res, [warning], []) async def test_platform_not_found_recovery_mode(hass: HomeAssistant) -> None: - """Test no errors if platform not found in recovery_mode.""" + """Test no errors if platform not found in recovery mode.""" # Make sure they don't exist files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:\n platform: beer"} hass.config.recovery_mode = True @@ -170,7 +244,128 @@ async def test_platform_not_found_recovery_mode(hass: HomeAssistant) -> None: assert res.keys() == {"homeassistant", "light"} assert res["light"] == [] - assert not res.errors + _assert_warnings_errors(res, [], []) + + +async def test_platform_not_found_safe_mode(hass: HomeAssistant) -> None: + """Test no errors if platform not found in safe mode.""" + # Make sure they don't exist + files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:\n platform: beer"} + hass.config.safe_mode = True + with patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert res.keys() == {"homeassistant", "light"} + assert res["light"] == [] + + _assert_warnings_errors(res, [], []) + + +@pytest.mark.parametrize( + ("extra_config", "warnings", "message", "config"), + [ + ( + "blah:\n - platform: test\n option1: abc", + 0, + None, + None, + ), + ( + "blah:\n - platform: test\n option1: 123", + 1, + "expected str for dictionary value", + {"option1": 123, "platform": "test"}, + ), + # Test the attached config is unvalidated (key old is removed by validator) + ( + "blah:\n - platform: test\n old: blah\n option1: 123", + 1, + "expected str for dictionary value", + {"old": "blah", "option1": 123, "platform": "test"}, + ), + # Test base platform configuration error + ( + "blah:\n - paltfrom: test\n", + 1, + "required key 'platform' not provided", + {"paltfrom": "test"}, + ), + ], +) +async def test_platform_schema_error( + hass: HomeAssistant, + extra_config: str, + warnings: int, + message: str | None, + config: dict | None, +) -> None: + """Test schema error in platform.""" + comp_platform_schema = cv.PLATFORM_SCHEMA.extend({vol.Remove("old"): str}) + comp_platform_schema_base = comp_platform_schema.extend({}, extra=vol.ALLOW_EXTRA) + mock_integration( + hass, + MockModule("blah", platform_schema_base=comp_platform_schema_base), + ) + test_platform_schema = comp_platform_schema.extend({"option1": str}) + mock_platform( + hass, + "test.blah", + MockPlatform(platform_schema=test_platform_schema), + ) + + files = {YAML_CONFIG_FILE: BASE_CONFIG + extra_config} + hass.config.safe_mode = True + with patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert len(res.errors) == 0 + assert len(res.warnings) == warnings + + for warn in res.warnings: + assert message in warn.message + assert warn.config == config + + +async def test_config_platform_import_error(hass: HomeAssistant) -> None: + """Test errors if config platform fails to import.""" + # Make sure they don't exist + files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:\n platform: beer"} + with patch( + "homeassistant.loader.Integration.get_platform", + side_effect=ImportError("blablabla"), + ), patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert res.keys() == {"homeassistant"} + error = CheckConfigError( + "Error importing config platform light: blablabla", + None, + None, + ) + _assert_warnings_errors(res, [], [error]) + + +async def test_platform_import_error(hass: HomeAssistant) -> None: + """Test errors if platform not found.""" + # Make sure they don't exist + files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:\n platform: demo"} + with patch( + "homeassistant.loader.Integration.get_platform", + side_effect=[None, ImportError("blablabla")], + ), patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert res.keys() == {"homeassistant", "light"} + warning = CheckConfigError( + "Platform error light.demo - blablabla", + None, + None, + ) + _assert_warnings_errors(res, [warning], []) async def test_package_invalid(hass: HomeAssistant) -> None: @@ -180,27 +375,32 @@ async def test_package_invalid(hass: HomeAssistant) -> None: res = await async_check_ha_config_file(hass) log_ha_config(res) - assert res.errors[0].domain == "homeassistant.packages.p1.group" - assert res.errors[0].config == {"group": ["a"]} - # Only 1 error expected - res.errors.pop(0) - assert not res.errors - assert res.keys() == {"homeassistant"} + warning = CheckConfigError( + ( + "Setup of package 'p1' failed: integration 'group' cannot be merged" + ", expected a dict" + ), + "homeassistant.packages.p1.group", + {"group": ["a"]}, + ) + _assert_warnings_errors(res, [warning], []) -async def test_bootstrap_error(hass: HomeAssistant) -> None: - """Test a valid platform setup.""" + +async def test_missing_included_file(hass: HomeAssistant) -> None: + """Test missing included file.""" files = {YAML_CONFIG_FILE: BASE_CONFIG + "automation: !include no.yaml"} with patch("os.path.isfile", return_value=True), patch_yaml_files(files): res = await async_check_ha_config_file(hass) log_ha_config(res) - assert res.errors[0].domain is None + assert len(res.errors) == 1 + assert len(res.warnings) == 0 - # Only 1 error expected - res.errors.pop(0) - assert not res.errors + assert res.errors[0].message.startswith("Error loading") + assert res.errors[0].domain is None + assert res.errors[0].config is None async def test_automation_config_platform(hass: HomeAssistant) -> None: @@ -216,9 +416,7 @@ automation: service_to_call: test.automation input_datetime: """, - hass.config.path( - "blueprints/automation/test_event_service.yaml" - ): """ + hass.config.path("blueprints/automation/test_event_service.yaml"): """ blueprint: name: "Call service based on event" domain: automation @@ -236,15 +434,39 @@ action: res = await async_check_ha_config_file(hass) assert len(res.get("automation", [])) == 1 assert len(res.errors) == 0 + assert len(res.warnings) == 0 assert "input_datetime" in res -async def test_config_platform_raise(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("exception", "errors", "warnings", "message"), + [ + ( + Exception("Broken"), + 1, + 0, + "Unexpected error calling config validator: Broken", + ), + ( + HomeAssistantError("Broken"), + 0, + 1, + "Invalid config for 'bla' at configuration.yaml, line 11: Broken", + ), + ], +) +async def test_config_platform_raise( + hass: HomeAssistant, + exception: Exception, + errors: int, + warnings: int, + message: str, +) -> None: """Test bad config validation platform.""" mock_platform( hass, "bla.config", - Mock(async_validate_config=Mock(side_effect=Exception("Broken"))), + Mock(async_validate_config=Mock(side_effect=exception)), ) files = { YAML_CONFIG_FILE: BASE_CONFIG @@ -255,11 +477,12 @@ bla: } with patch("os.path.isfile", return_value=True), patch_yaml_files(files): res = await async_check_ha_config_file(hass) - assert len(res.errors) == 1 - err = res.errors[0] - assert err.domain == "bla" - assert err.message == "Unexpected error calling config validator: Broken" - assert err.config == {"value": 1} + error = CheckConfigError( + message, + "bla", + {"value": 1}, + ) + _assert_warnings_errors(res, [error] * warnings, [error] * errors) async def test_removed_yaml_support(hass: HomeAssistant) -> None: @@ -277,3 +500,4 @@ async def test_removed_yaml_support(hass: HomeAssistant) -> None: log_ha_config(res) assert res.keys() == {"homeassistant"} + _assert_warnings_errors(res, [], []) diff --git a/tests/helpers/test_collection.py b/tests/helpers/test_collection.py index 7969e02ab2f..a385ca8aeb6 100644 --- a/tests/helpers/test_collection.py +++ b/tests/helpers/test_collection.py @@ -293,7 +293,9 @@ async def test_attach_entity_component_collection(hass: HomeAssistant) -> None: assert hass.states.get("test.mock_1") is None -async def test_entity_component_collection_abort(hass: HomeAssistant) -> None: +async def test_entity_component_collection_abort( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test aborted entity adding is handled.""" ent_comp = entity_component.EntityComponent(_LOGGER, "test", hass) await ent_comp.async_setup({}) @@ -318,7 +320,6 @@ async def test_entity_component_collection_abort(hass: HomeAssistant) -> None: collection.sync_entity_lifecycle( hass, "test", "test", ent_comp, coll, MockMockEntity ) - entity_registry = er.async_get(hass) entity_registry.async_get_or_create( "test", "test", @@ -360,7 +361,9 @@ async def test_entity_component_collection_abort(hass: HomeAssistant) -> None: assert len(async_remove_calls) == 0 -async def test_entity_component_collection_entity_removed(hass: HomeAssistant) -> None: +async def test_entity_component_collection_entity_removed( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test entity removal is handled.""" ent_comp = entity_component.EntityComponent(_LOGGER, "test", hass) await ent_comp.async_setup({}) @@ -385,7 +388,6 @@ async def test_entity_component_collection_entity_removed(hass: HomeAssistant) - collection.sync_entity_lifecycle( hass, "test", "test", ent_comp, coll, MockMockEntity ) - entity_registry = er.async_get(hass) entity_registry.async_get_or_create( "test", "test", "mock_id", suggested_object_id="mock_1" ) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 2512f426f13..3b8217028cc 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -1373,10 +1373,11 @@ async def test_state_attribute_boolean(hass: HomeAssistant) -> None: assert test(hass) -async def test_state_entity_registry_id(hass: HomeAssistant) -> None: +async def test_state_entity_registry_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test with entity specified by entity registry id.""" - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( "switch", "hue", "1234", suggested_object_id="test" ) assert entry.entity_id == "switch.test" @@ -1715,10 +1716,11 @@ async def test_numeric_state_attribute(hass: HomeAssistant) -> None: assert not test(hass) -async def test_numeric_state_entity_registry_id(hass: HomeAssistant) -> None: +async def test_numeric_state_entity_registry_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test with entity specified by entity registry id.""" - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( "sensor", "hue", "1234", suggested_object_id="test" ) assert entry.entity_id == "sensor.test" diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 90d8030be79..71c81b096ca 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -9,12 +9,7 @@ from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_flow -from tests.common import ( - MockConfigEntry, - MockModule, - mock_entity_platform, - mock_integration, -) +from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform @pytest.fixture @@ -77,7 +72,7 @@ async def test_user_has_confirmation( ) -> None: """Test user requires confirmation to setup.""" discovery_flow_conf["discovered"] = True - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) result = await hass.config_entries.flow.async_init( "test", context={"source": config_entries.SOURCE_USER}, data={} @@ -184,7 +179,7 @@ async def test_multiple_discoveries( hass: HomeAssistant, discovery_flow_conf: dict[str, bool] ) -> None: """Test we only create one instance for multiple discoveries.""" - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) result = await hass.config_entries.flow.async_init( "test", context={"source": config_entries.SOURCE_DISCOVERY}, data={} @@ -202,7 +197,7 @@ async def test_only_one_in_progress( hass: HomeAssistant, discovery_flow_conf: dict[str, bool] ) -> None: """Test a user initialized one will finish and cancel discovered one.""" - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) # Discovery starts flow result = await hass.config_entries.flow.async_init( @@ -230,7 +225,7 @@ async def test_import_abort_discovery( hass: HomeAssistant, discovery_flow_conf: dict[str, bool] ) -> None: """Test import will finish and cancel discovered one.""" - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) # Discovery starts flow result = await hass.config_entries.flow.async_init( @@ -280,7 +275,7 @@ async def test_ignored_discoveries( hass: HomeAssistant, discovery_flow_conf: dict[str, bool] ) -> None: """Test we can ignore discovered entries.""" - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) result = await hass.config_entries.flow.async_init( "test", context={"source": config_entries.SOURCE_DISCOVERY}, data={} @@ -373,7 +368,7 @@ async def test_webhook_create_cloudhook( async_remove_entry=config_entry_flow.webhook_async_remove_entry, ), ) - mock_entity_platform(hass, "config_flow.test_single", None) + mock_platform(hass, "test_single.config_flow", None) result = await hass.config_entries.flow.async_init( "test_single", context={"source": config_entries.SOURCE_USER} @@ -428,7 +423,7 @@ async def test_webhook_create_cloudhook_aborts_not_connected( async_remove_entry=config_entry_flow.webhook_async_remove_entry, ), ) - mock_entity_platform(hass, "config_flow.test_single", None) + mock_platform(hass, "test_single.config_flow", None) result = await hass.config_entries.flow.async_init( "test_single", context={"source": config_entries.SOURCE_USER} diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index c36b62f66c0..8c78b7dadc6 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -1,7 +1,9 @@ """Tests for the Somfy config flow.""" import asyncio +from http import HTTPStatus import logging import time +from typing import Any from unittest.mock import patch import aiohttp @@ -339,7 +341,7 @@ async def test_abort_on_oauth_timeout_error( result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "oauth2_timeout" + assert result["reason"] == "oauth_timeout" async def test_step_discovery(hass: HomeAssistant, flow_handler, local_impl) -> None: @@ -387,6 +389,164 @@ async def test_abort_discovered_multiple( assert result["reason"] == "already_in_progress" +@pytest.mark.parametrize( + ("status_code", "error_body", "error_reason", "error_log"), + [ + ( + HTTPStatus.UNAUTHORIZED, + {}, + "oauth_unauthorized", + "Token request failed (unknown): unknown", + ), + ( + HTTPStatus.NOT_FOUND, + {}, + "oauth_failed", + "Token request failed (unknown): unknown", + ), + ( + HTTPStatus.INTERNAL_SERVER_ERROR, + {}, + "oauth_failed", + "Token request failed (unknown): unknown", + ), + ( + HTTPStatus.BAD_REQUEST, + { + "error": "invalid_request", + "error_description": "Request was missing the 'redirect_uri' parameter.", + "error_uri": "See the full API docs at https://authorization-server.com/docs/access_token", + }, + "oauth_failed", + "Token request failed (invalid_request): Request was missing the", + ), + ], +) +async def test_abort_if_oauth_token_error( + hass: HomeAssistant, + flow_handler, + local_impl, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + status_code: HTTPStatus, + error_body: dict[str, Any], + error_reason: str, + error_log: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Check error when obtaining an oauth token.""" + flow_handler.async_register_implementation(hass, local_impl) + config_entry_oauth2_flow.async_register_implementation( + hass, TEST_DOMAIN, MockOAuth2Implementation() + ) + + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "pick_implementation" + + # Pick implementation + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"implementation": TEST_DOMAIN} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{AUTHORIZE_URL}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=read+write" + ) + + 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( + TOKEN_URL, + status=status_code, + json=error_body, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == error_reason + assert error_log in caplog.text + + +async def test_abort_if_oauth_token_closing_error( + hass: HomeAssistant, + flow_handler, + local_impl, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Check error when obtaining an oauth token.""" + flow_handler.async_register_implementation(hass, local_impl) + config_entry_oauth2_flow.async_register_implementation( + hass, TEST_DOMAIN, MockOAuth2Implementation() + ) + + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "pick_implementation" + + # Pick implementation + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"implementation": TEST_DOMAIN} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{AUTHORIZE_URL}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=read+write" + ) + + 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( + TOKEN_URL, + status=HTTPStatus.UNAUTHORIZED, + closing=True, + ) + + with caplog.at_level(logging.DEBUG): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert "Token request failed (unknown): unknown" in caplog.text + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "oauth_unauthorized" + + async def test_abort_discovered_existing_entries( hass: HomeAssistant, flow_handler, local_impl ) -> None: diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index a9ddd89a0b3..6d1945f2d5f 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -832,6 +832,7 @@ def test_selector_in_serializer() -> None: "selector": { "text": { "multiline": False, + "multiple": False, } } } diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 89f4eb5e319..657d8871e66 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -1326,7 +1326,9 @@ async def test_update_suggested_area( async def test_cleanup_device_registry( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test cleanup works.""" config_entry = MockConfigEntry(domain="hue") @@ -1349,13 +1351,12 @@ async def test_cleanup_device_registry( # Remove the config entry without triggering the normal cleanup hass.config_entries._entries.pop(ghost_config_entry.entry_id) - ent_reg = er.async_get(hass) - ent_reg.async_get_or_create("light", "hue", "e1", device_id=d1.id) - ent_reg.async_get_or_create("light", "hue", "e2", device_id=d1.id) - ent_reg.async_get_or_create("light", "hue", "e3", device_id=d3.id) + entity_registry.async_get_or_create("light", "hue", "e1", device_id=d1.id) + entity_registry.async_get_or_create("light", "hue", "e2", device_id=d1.id) + entity_registry.async_get_or_create("light", "hue", "e3", device_id=d3.id) # Manual cleanup should detect the orphaned config entry - dr.async_cleanup(hass, device_registry, ent_reg) + dr.async_cleanup(hass, device_registry, entity_registry) assert device_registry.async_get_device(identifiers={("hue", "d1")}) is not None assert device_registry.async_get_device(identifiers={("hue", "d2")}) is not None @@ -1364,7 +1365,9 @@ async def test_cleanup_device_registry( async def test_cleanup_device_registry_removes_expired_orphaned_devices( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test cleanup removes expired orphaned devices.""" config_entry = MockConfigEntry(domain="hue") @@ -1384,8 +1387,7 @@ async def test_cleanup_device_registry_removes_expired_orphaned_devices( assert len(device_registry.devices) == 0 assert len(device_registry.deleted_devices) == 3 - ent_reg = er.async_get(hass) - dr.async_cleanup(hass, device_registry, ent_reg) + dr.async_cleanup(hass, device_registry, entity_registry) assert len(device_registry.devices) == 0 assert len(device_registry.deleted_devices) == 3 @@ -1393,7 +1395,7 @@ async def test_cleanup_device_registry_removes_expired_orphaned_devices( future_time = time.time() + dr.ORPHANED_DEVICE_KEEP_SECONDS + 1 with patch("time.time", return_value=future_time): - dr.async_cleanup(hass, device_registry, ent_reg) + dr.async_cleanup(hass, device_registry, entity_registry) assert len(device_registry.devices) == 0 assert len(device_registry.deleted_devices) == 0 diff --git a/tests/helpers/test_discovery.py b/tests/helpers/test_discovery.py index 2900cb2c09e..d73bfe84607 100644 --- a/tests/helpers/test_discovery.py +++ b/tests/helpers/test_discovery.py @@ -9,12 +9,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import discovery from homeassistant.helpers.dispatcher import async_dispatcher_send -from tests.common import ( - MockModule, - MockPlatform, - mock_entity_platform, - mock_integration, -) +from tests.common import MockModule, MockPlatform, mock_integration, mock_platform @pytest.fixture @@ -136,7 +131,7 @@ async def test_circular_import(hass: HomeAssistant) -> None: # dependencies are only set in component level # since we are using manifest to hold them mock_integration(hass, MockModule("test_circular", dependencies=["test_component"])) - mock_entity_platform(hass, "switch.test_circular", MockPlatform(setup_platform)) + mock_platform(hass, "test_circular.switch", MockPlatform(setup_platform)) await setup.async_setup_component( hass, diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index cf76083fe7a..4076afcfad0 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -590,7 +590,6 @@ async def test_async_remove_runs_callbacks(hass: HomeAssistant) -> None: platform = MockEntityPlatform(hass, domain="test") ent = entity.Entity() - ent.hass = hass ent.entity_id = "test.test" await platform.async_add_entities([ent]) ent.async_on_remove(lambda: result.append(1)) @@ -604,7 +603,6 @@ async def test_async_remove_ignores_in_flight_polling(hass: HomeAssistant) -> No platform = MockEntityPlatform(hass, domain="test") ent = entity.Entity() - ent.hass = hass ent.entity_id = "test.test" ent.async_on_remove(lambda: result.append(1)) await platform.async_add_entities([ent]) @@ -621,6 +619,34 @@ async def test_async_remove_ignores_in_flight_polling(hass: HomeAssistant) -> No assert hass.states.get("test.test") is None +async def test_async_remove_twice(hass: HomeAssistant) -> None: + """Test removing an entity twice only cleans up once.""" + result = [] + + class MockEntity(entity.Entity): + def __init__(self) -> None: + self.remove_calls = [] + + async def async_will_remove_from_hass(self): + self.remove_calls.append(None) + + platform = MockEntityPlatform(hass, domain="test") + ent = MockEntity() + ent.hass = hass + ent.entity_id = "test.test" + ent.async_on_remove(lambda: result.append(1)) + await platform.async_add_entities([ent]) + assert hass.states.get("test.test").state == STATE_UNKNOWN + + await ent.async_remove() + assert len(result) == 1 + assert len(ent.remove_calls) == 1 + + await ent.async_remove() + assert len(result) == 1 + assert len(ent.remove_calls) == 1 + + async def test_set_context(hass: HomeAssistant) -> None: """Test setting context.""" context = Context() @@ -813,13 +839,6 @@ async def test_setup_source(hass: HomeAssistant) -> None: async def test_removing_entity_unavailable(hass: HomeAssistant) -> None: """Test removing an entity that is still registered creates an unavailable state.""" - er.RegistryEntry( - entity_id="hello.world", - unique_id="test-unique-id", - platform="test-platform", - disabled_by=None, - ) - platform = MockEntityPlatform(hass, domain="hello") ent = entity.Entity() ent.entity_id = "hello.world" @@ -1528,3 +1547,113 @@ async def test_suggest_report_issue_custom_component( suggestion = mock_entity._suggest_report_issue() assert suggestion == "create a bug report at https://some_url" + + +async def test_reuse_entity_object_after_abort(hass: HomeAssistant) -> None: + """Test reuse entity object.""" + platform = MockEntityPlatform(hass, domain="test") + ent = entity.Entity() + ent.entity_id = "invalid" + with pytest.raises(HomeAssistantError, match="Invalid entity ID: invalid"): + await platform.async_add_entities([ent]) + with pytest.raises( + HomeAssistantError, + match="Entity 'invalid' cannot be added a second time to an entity platform", + ): + await platform.async_add_entities([ent]) + + +async def test_reuse_entity_object_after_entity_registry_remove( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test reuse entity object.""" + entry = entity_registry.async_get_or_create("test", "test", "5678") + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + ent = entity.Entity() + ent._attr_unique_id = "5678" + await platform.async_add_entities([ent]) + assert ent.registry_entry is entry + assert len(hass.states.async_entity_ids()) == 1 + + entity_registry.async_remove(entry.entity_id) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids()) == 0 + + with pytest.raises( + HomeAssistantError, + match="Entity 'test.test_5678' cannot be added a second time", + ): + await platform.async_add_entities([ent]) + + +async def test_reuse_entity_object_after_entity_registry_disabled( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test reuse entity object.""" + entry = entity_registry.async_get_or_create("test", "test", "5678") + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + ent = entity.Entity() + ent._attr_unique_id = "5678" + await platform.async_add_entities([ent]) + assert ent.registry_entry is entry + assert len(hass.states.async_entity_ids()) == 1 + + entity_registry.async_update_entity( + entry.entity_id, disabled_by=er.RegistryEntryDisabler.USER + ) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids()) == 0 + + with pytest.raises( + HomeAssistantError, + match="Entity 'test.test_5678' cannot be added a second time", + ): + await platform.async_add_entities([ent]) + + +async def test_change_entity_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test changing entity id.""" + result = [] + + entry = entity_registry.async_get_or_create( + "test", "test_platform", "5678", suggested_object_id="test" + ) + assert entry.entity_id == "test.test" + + class MockEntity(entity.Entity): + _attr_unique_id = "5678" + + def __init__(self) -> None: + self.added_calls = [] + self.remove_calls = [] + + async def async_added_to_hass(self): + self.added_calls.append(None) + self.async_on_remove(lambda: result.append(1)) + + async def async_will_remove_from_hass(self): + self.remove_calls.append(None) + + platform = MockEntityPlatform(hass, domain="test") + ent = MockEntity() + await platform.async_add_entities([ent]) + assert hass.states.get("test.test").state == STATE_UNKNOWN + assert len(ent.added_calls) == 1 + + entry = entity_registry.async_update_entity( + entry.entity_id, new_entity_id="test.test2" + ) + await hass.async_block_till_done() + + assert len(result) == 1 + assert len(ent.added_calls) == 2 + assert len(ent.remove_calls) == 1 + + entity_registry.async_update_entity(entry.entity_id, new_entity_id="test.test3") + await hass.async_block_till_done() + + assert len(result) == 2 + assert len(ent.added_calls) == 3 + assert len(ent.remove_calls) == 2 diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 4119ccc6e85..40e25633992 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -35,8 +35,8 @@ from tests.common import ( MockModule, MockPlatform, async_fire_time_changed, - mock_entity_platform, mock_integration, + mock_platform, ) _LOGGER = logging.getLogger(__name__) @@ -51,7 +51,7 @@ async def test_setup_loads_platforms(hass: HomeAssistant) -> None: mock_integration(hass, MockModule("test_component", setup=component_setup)) # mock the dependencies mock_integration(hass, MockModule("mod2", dependencies=["test_component"])) - mock_entity_platform(hass, "test_domain.mod2", MockPlatform(platform_setup)) + mock_platform(hass, "mod2.test_domain", MockPlatform(platform_setup)) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -70,8 +70,8 @@ async def test_setup_recovers_when_setup_raises(hass: HomeAssistant) -> None: platform1_setup = Mock(side_effect=Exception("Broken")) platform2_setup = Mock(return_value=None) - mock_entity_platform(hass, "test_domain.mod1", MockPlatform(platform1_setup)) - mock_entity_platform(hass, "test_domain.mod2", MockPlatform(platform2_setup)) + mock_platform(hass, "mod1.test_domain", MockPlatform(platform1_setup)) + mock_platform(hass, "mod2.test_domain", MockPlatform(platform2_setup)) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -130,7 +130,7 @@ async def test_set_scan_interval_via_config( """Test the platform setup.""" add_entities([MockEntity(should_poll=True)]) - mock_entity_platform(hass, "test_domain.platform", MockPlatform(platform_setup)) + mock_platform(hass, "platform.test_domain", MockPlatform(platform_setup)) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -157,7 +157,7 @@ async def test_set_entity_namespace_via_config(hass: HomeAssistant) -> None: platform = MockPlatform(platform_setup) - mock_entity_platform(hass, "test_domain.platform", platform) + mock_platform(hass, "platform.test_domain", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -205,7 +205,7 @@ async def test_platform_not_ready(hass: HomeAssistant) -> None: """Test that we retry when platform not ready.""" platform1_setup = Mock(side_effect=[PlatformNotReady, PlatformNotReady, None]) mock_integration(hass, MockModule("mod1")) - mock_entity_platform(hass, "test_domain.mod1", MockPlatform(platform1_setup)) + mock_platform(hass, "mod1.test_domain", MockPlatform(platform1_setup)) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -309,7 +309,7 @@ async def test_setup_dependencies_platform(hass: HomeAssistant) -> None: hass, MockModule("test_component", dependencies=["test_component2"]) ) mock_integration(hass, MockModule("test_component2")) - mock_entity_platform(hass, "test_domain.test_component", MockPlatform()) + mock_platform(hass, "test_component.test_domain", MockPlatform()) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -323,9 +323,9 @@ async def test_setup_dependencies_platform(hass: HomeAssistant) -> None: async def test_setup_entry(hass: HomeAssistant) -> None: """Test setup entry calls async_setup_entry on platform.""" mock_setup_entry = AsyncMock(return_value=True) - mock_entity_platform( + mock_platform( hass, - "test_domain.entry_domain", + "entry_domain.test_domain", MockPlatform( async_setup_entry=mock_setup_entry, scan_interval=timedelta(seconds=5) ), @@ -354,9 +354,9 @@ async def test_setup_entry_platform_not_exist(hass: HomeAssistant) -> None: async def test_setup_entry_fails_duplicate(hass: HomeAssistant) -> None: """Test we don't allow setting up a config entry twice.""" mock_setup_entry = AsyncMock(return_value=True) - mock_entity_platform( + mock_platform( hass, - "test_domain.entry_domain", + "entry_domain.test_domain", MockPlatform(async_setup_entry=mock_setup_entry), ) @@ -372,9 +372,9 @@ async def test_setup_entry_fails_duplicate(hass: HomeAssistant) -> None: async def test_unload_entry_resets_platform(hass: HomeAssistant) -> None: """Test unloading an entry removes all entities.""" mock_setup_entry = AsyncMock(return_value=True) - mock_entity_platform( + mock_platform( hass, - "test_domain.entry_domain", + "entry_domain.test_domain", MockPlatform(async_setup_entry=mock_setup_entry), ) @@ -531,7 +531,7 @@ async def test_register_entity_service(hass: HomeAssistant) -> None: async def test_register_entity_service_response_data(hass: HomeAssistant) -> None: - """Test an enttiy service that does not support response data.""" + """Test an entity service that does support response data.""" entity = MockEntity(entity_id=f"{DOMAIN}.entity") async def generate_response( @@ -554,24 +554,25 @@ async def test_register_entity_service_response_data(hass: HomeAssistant) -> Non response_data = await hass.services.async_call( DOMAIN, "hello", - service_data={"entity_id": entity.entity_id, "some": "data"}, + service_data={"some": "data"}, + target={"entity_id": [entity.entity_id]}, blocking=True, return_response=True, ) - assert response_data == {"response-key": "response-value"} + assert response_data == {f"{DOMAIN}.entity": {"response-key": "response-value"}} async def test_register_entity_service_response_data_multiple_matches( hass: HomeAssistant, ) -> None: - """Test asking for service response data but matching many entities.""" + """Test asking for service response data and matching many entities.""" entity1 = MockEntity(entity_id=f"{DOMAIN}.entity1") entity2 = MockEntity(entity_id=f"{DOMAIN}.entity2") async def generate_response( target: MockEntity, call: ServiceCall ) -> ServiceResponse: - raise ValueError("Should not be invoked") + return {"response-key": f"response-value-{target.entity_id}"} component = EntityComponent(_LOGGER, DOMAIN, hass) await component.async_setup({}) @@ -579,7 +580,80 @@ async def test_register_entity_service_response_data_multiple_matches( component.async_register_entity_service( "hello", - {}, + {"some": str}, + generate_response, + supports_response=SupportsResponse.ONLY, + ) + + response_data = await hass.services.async_call( + DOMAIN, + "hello", + service_data={"some": "data"}, + target={"entity_id": [entity1.entity_id, entity2.entity_id]}, + blocking=True, + return_response=True, + ) + assert response_data == { + f"{DOMAIN}.entity1": {"response-key": f"response-value-{DOMAIN}.entity1"}, + f"{DOMAIN}.entity2": {"response-key": f"response-value-{DOMAIN}.entity2"}, + } + + +async def test_register_entity_service_response_data_multiple_matches_raises( + hass: HomeAssistant, +) -> None: + """Test asking for service response data and matching many entities raises exceptions.""" + entity1 = MockEntity(entity_id=f"{DOMAIN}.entity1") + entity2 = MockEntity(entity_id=f"{DOMAIN}.entity2") + + async def generate_response( + target: MockEntity, call: ServiceCall + ) -> ServiceResponse: + if target.entity_id == f"{DOMAIN}.entity1": + raise RuntimeError("Something went wrong") + return {"response-key": f"response-value-{target.entity_id}"} + + component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) + await component.async_add_entities([entity1, entity2]) + + component.async_register_entity_service( + "hello", + {"some": str}, + generate_response, + supports_response=SupportsResponse.ONLY, + ) + + with pytest.raises(RuntimeError, match="Something went wrong"): + await hass.services.async_call( + DOMAIN, + "hello", + service_data={"some": "data"}, + target={"entity_id": [entity1.entity_id, entity2.entity_id]}, + blocking=True, + return_response=True, + ) + + +async def test_legacy_register_entity_service_response_data_multiple_matches( + hass: HomeAssistant, +) -> None: + """Test asking for legacy service response data but matching many entities.""" + entity1 = MockEntity(entity_id=f"{DOMAIN}.entity1") + entity2 = MockEntity(entity_id=f"{DOMAIN}.entity2") + + async def generate_response( + target: MockEntity, call: ServiceCall + ) -> ServiceResponse: + return {"response-key": "response-value"} + + component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) + await component.async_add_entities([entity1, entity2]) + + component.async_register_legacy_entity_service( + "hello", + {"some": str}, generate_response, supports_response=SupportsResponse.ONLY, ) @@ -588,6 +662,7 @@ async def test_register_entity_service_response_data_multiple_matches( await hass.services.async_call( DOMAIN, "hello", + service_data={"some": "data"}, target={"entity_id": [entity1.entity_id, entity2.entity_id]}, blocking=True, return_response=True, @@ -598,7 +673,7 @@ async def test_platforms_shutdown_on_stop(hass: HomeAssistant) -> None: """Test that we shutdown platforms on stop.""" platform1_setup = Mock(side_effect=[PlatformNotReady, PlatformNotReady, None]) mock_integration(hass, MockModule("mod1")) - mock_entity_platform(hass, "test_domain.mod1", MockPlatform(platform1_setup)) + mock_platform(hass, "mod1.test_domain", MockPlatform(platform1_setup)) component = EntityComponent(_LOGGER, DOMAIN, hass) diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 0bbfedb8926..721114c1a7b 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -9,7 +9,14 @@ from unittest.mock import ANY, Mock, patch import pytest from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, PERCENTAGE -from homeassistant.core import CoreState, HomeAssistant, callback +from homeassistant.core import ( + CoreState, + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers import ( device_registry as dr, @@ -36,7 +43,7 @@ from tests.common import ( MockEntityPlatform, MockPlatform, async_fire_time_changed, - mock_entity_platform, + mock_platform, mock_registry, ) @@ -188,7 +195,7 @@ async def test_set_scan_interval_via_platform( platform = MockPlatform(platform_setup) platform.SCAN_INTERVAL = timedelta(seconds=30) - mock_entity_platform(hass, "test_domain.platform", platform) + mock_platform(hass, "platform.test_domain", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -223,7 +230,7 @@ async def test_platform_warn_slow_setup(hass: HomeAssistant) -> None: """Warn we log when platform setup takes a long time.""" platform = MockPlatform() - mock_entity_platform(hass, "test_domain.platform", platform) + mock_platform(hass, "platform.test_domain", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -257,7 +264,7 @@ async def test_platform_error_slow_setup( platform = MockPlatform(async_setup_platform=setup_platform) component = EntityComponent(_LOGGER, DOMAIN, hass) - mock_entity_platform(hass, "test_domain.test_platform", platform) + mock_platform(hass, "test_platform.test_domain", platform) await component.async_setup({DOMAIN: {"platform": "test_platform"}}) await hass.async_block_till_done() assert len(called) == 1 @@ -291,7 +298,7 @@ async def test_parallel_updates_async_platform(hass: HomeAssistant) -> None: """Test async platform does not have parallel_updates limit by default.""" platform = MockPlatform() - mock_entity_platform(hass, "test_domain.platform", platform) + mock_platform(hass, "platform.test_domain", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) component._platforms = {} @@ -321,7 +328,7 @@ async def test_parallel_updates_async_platform_with_constant( platform = MockPlatform() platform.PARALLEL_UPDATES = 2 - mock_entity_platform(hass, "test_domain.platform", platform) + mock_platform(hass, "platform.test_domain", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) component._platforms = {} @@ -348,7 +355,7 @@ async def test_parallel_updates_sync_platform(hass: HomeAssistant) -> None: """Test sync platform parallel_updates default set to 1.""" platform = MockPlatform() - mock_entity_platform(hass, "test_domain.platform", platform) + mock_platform(hass, "platform.test_domain", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) component._platforms = {} @@ -374,7 +381,7 @@ async def test_parallel_updates_no_update_method(hass: HomeAssistant) -> None: """Test platform parallel_updates default set to 0.""" platform = MockPlatform() - mock_entity_platform(hass, "test_domain.platform", platform) + mock_platform(hass, "platform.test_domain", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) component._platforms = {} @@ -396,7 +403,7 @@ async def test_parallel_updates_sync_platform_with_constant( platform = MockPlatform() platform.PARALLEL_UPDATES = 2 - mock_entity_platform(hass, "test_domain.platform", platform) + mock_platform(hass, "platform.test_domain", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) component._platforms = {} @@ -424,7 +431,7 @@ async def test_parallel_updates_async_platform_updates_in_parallel( """Test an async platform is updated in parallel.""" platform = MockPlatform() - mock_entity_platform(hass, "test_domain.async_platform", platform) + mock_platform(hass, "async_platform.test_domain", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) component._platforms = {} @@ -472,7 +479,7 @@ async def test_parallel_updates_sync_platform_updates_in_sequence( """Test a sync platform is updated in sequence.""" platform = MockPlatform() - mock_entity_platform(hass, "test_domain.platform", platform) + mock_platform(hass, "platform.test_domain", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) component._platforms = {} @@ -558,28 +565,38 @@ async def test_async_remove_with_platform_update_finishes(hass: HomeAssistant) - component = EntityComponent(_LOGGER, DOMAIN, hass) await component.async_setup({}) entity1 = MockEntity(name="test_1") + entity2 = MockEntity(name="test_1") async def _delayed_update(*args, **kwargs): - await asyncio.sleep(0.01) + update_called.set() + await update_done.wait() entity1.async_update = _delayed_update + entity2.async_update = _delayed_update - # Add, remove, add, remove and make sure no updates - # cause the entity to reappear after removal - for _ in range(2): - await component.async_add_entities([entity1]) - assert len(hass.states.async_entity_ids()) == 1 - entity1.async_write_ha_state() - assert hass.states.get(entity1.entity_id) is not None - task = asyncio.create_task(entity1.async_update_ha_state(True)) - await entity1.async_remove() - assert len(hass.states.async_entity_ids()) == 0 + # Add, remove, and make sure no updates + # cause the entity to reappear after removal and + # that we can add another entity with the same entity_id + for entity in [entity1, entity2]: + update_called = asyncio.Event() + update_done = asyncio.Event() + await component.async_add_entities([entity]) + assert hass.states.async_entity_ids() == ["test_domain.test_1"] + entity.async_write_ha_state() + assert hass.states.get(entity.entity_id) is not None + task = asyncio.create_task(entity.async_update_ha_state(True)) + await update_called.wait() + await entity.async_remove() + assert hass.states.async_entity_ids() == [] + update_done.set() await task - assert len(hass.states.async_entity_ids()) == 0 + assert hass.states.async_entity_ids() == [] async def test_not_adding_duplicate_entities_with_unique_id( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test for not adding duplicate entities. @@ -612,9 +629,8 @@ async def test_not_adding_duplicate_entities_with_unique_id( assert ent2.platform is None assert len(hass.states.async_entity_ids()) == 1 - registry = er.async_get(hass) # test the entity name was not updated - entry = registry.async_get_or_create(DOMAIN, DOMAIN, "not_very_unique") + entry = entity_registry.async_get_or_create(DOMAIN, DOMAIN, "not_very_unique") assert entry.original_name == "test1" @@ -744,7 +760,9 @@ async def test_registry_respect_entity_disabled(hass: HomeAssistant) -> None: async def test_unique_id_conflict_has_priority_over_disabled_entity( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that an entity that is not unique has priority over a disabled entity.""" component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -762,9 +780,8 @@ async def test_unique_id_conflict_has_priority_over_disabled_entity( assert "Platform test_domain does not generate unique IDs." in caplog.text assert entity1.registry_entry is not None assert entity2.registry_entry is None - registry = er.async_get(hass) # test the entity name was not updated - entry = registry.async_get_or_create(DOMAIN, DOMAIN, "not_very_unique") + entry = entity_registry.async_get_or_create(DOMAIN, DOMAIN, "not_very_unique") assert entry.original_name == "test1" @@ -1059,12 +1076,13 @@ async def test_entity_registry_updates_invalid_entity_id(hass: HomeAssistant) -> assert hass.states.get("diff_domain.world") is None -async def test_device_info_called(hass: HomeAssistant) -> None: +async def test_device_info_called( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test device info is forwarded correctly.""" - registry = dr.async_get(hass) config_entry = MockConfigEntry(entry_id="super-mock-id") config_entry.add_to_hass(hass) - via = registry.async_get_or_create( + via = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections=set(), identifiers={("hue", "via-id")}, @@ -1109,7 +1127,7 @@ async def test_device_info_called(hass: HomeAssistant) -> None: assert len(hass.states.async_entity_ids()) == 2 - device = registry.async_get_device(identifiers={("hue", "1234")}) + device = device_registry.async_get_device(identifiers={("hue", "1234")}) assert device is not None assert device.identifiers == {("hue", "1234")} assert device.configuration_url == "http://192.168.0.100/config" @@ -1124,12 +1142,13 @@ async def test_device_info_called(hass: HomeAssistant) -> None: assert device.via_device_id == via.id -async def test_device_info_not_overrides(hass: HomeAssistant) -> None: +async def test_device_info_not_overrides( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test device info is forwarded correctly.""" - registry = dr.async_get(hass) config_entry = MockConfigEntry(entry_id="super-mock-id") config_entry.add_to_hass(hass) - device = registry.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "abcd")}, manufacturer="test-manufacturer", @@ -1164,7 +1183,7 @@ async def test_device_info_not_overrides(hass: HomeAssistant) -> None: assert await entity_platform.async_setup_entry(config_entry) await hass.async_block_till_done() - device2 = registry.async_get_device( + device2 = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, "abcd")} ) assert device2 is not None @@ -1174,13 +1193,14 @@ async def test_device_info_not_overrides(hass: HomeAssistant) -> None: async def test_device_info_homeassistant_url( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test device info with homeassistant URL.""" - registry = dr.async_get(hass) config_entry = MockConfigEntry(entry_id="super-mock-id") config_entry.add_to_hass(hass) - registry.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections=set(), identifiers={("mqtt", "via-id")}, @@ -1214,20 +1234,21 @@ async def test_device_info_homeassistant_url( assert len(hass.states.async_entity_ids()) == 1 - device = registry.async_get_device(identifiers={("mqtt", "1234")}) + device = device_registry.async_get_device(identifiers={("mqtt", "1234")}) assert device is not None assert device.identifiers == {("mqtt", "1234")} assert device.configuration_url == "homeassistant://config/mqtt" async def test_device_info_change_to_no_url( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test device info changes to no URL.""" - registry = dr.async_get(hass) config_entry = MockConfigEntry(entry_id="super-mock-id") config_entry.add_to_hass(hass) - registry.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections=set(), identifiers={("mqtt", "via-id")}, @@ -1262,13 +1283,15 @@ async def test_device_info_change_to_no_url( assert len(hass.states.async_entity_ids()) == 1 - device = registry.async_get_device(identifiers={("mqtt", "1234")}) + device = device_registry.async_get_device(identifiers={("mqtt", "1234")}) assert device is not None assert device.identifiers == {("mqtt", "1234")} assert device.configuration_url is None -async def test_entity_disabled_by_integration(hass: HomeAssistant) -> None: +async def test_entity_disabled_by_integration( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test entity disabled by integration.""" component = EntityComponent(_LOGGER, DOMAIN, hass, timedelta(seconds=20)) await component.async_setup({}) @@ -1285,15 +1308,17 @@ async def test_entity_disabled_by_integration(hass: HomeAssistant) -> None: assert entity_disabled.hass is None assert entity_disabled.platform is None - registry = er.async_get(hass) - - entry_default = registry.async_get_or_create(DOMAIN, DOMAIN, "default") + entry_default = entity_registry.async_get_or_create(DOMAIN, DOMAIN, "default") assert entry_default.disabled_by is None - entry_disabled = registry.async_get_or_create(DOMAIN, DOMAIN, "disabled") + entry_disabled = entity_registry.async_get_or_create(DOMAIN, DOMAIN, "disabled") assert entry_disabled.disabled_by is er.RegistryEntryDisabler.INTEGRATION -async def test_entity_disabled_by_device(hass: HomeAssistant) -> None: +async def test_entity_disabled_by_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test entity disabled by device.""" connections = {(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")} @@ -1313,7 +1338,6 @@ async def test_entity_disabled_by_device(hass: HomeAssistant) -> None: hass, platform_name=config_entry.domain, platform=platform ) - device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections=connections, @@ -1326,13 +1350,13 @@ async def test_entity_disabled_by_device(hass: HomeAssistant) -> None: assert entity_disabled.hass is None assert entity_disabled.platform is None - registry = er.async_get(hass) - - entry_disabled = registry.async_get_or_create(DOMAIN, DOMAIN, "disabled") + entry_disabled = entity_registry.async_get_or_create(DOMAIN, DOMAIN, "disabled") assert entry_disabled.disabled_by is er.RegistryEntryDisabler.DEVICE -async def test_entity_hidden_by_integration(hass: HomeAssistant) -> None: +async def test_entity_hidden_by_integration( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test entity hidden by integration.""" component = EntityComponent(_LOGGER, DOMAIN, hass, timedelta(seconds=20)) await component.async_setup({}) @@ -1344,15 +1368,15 @@ async def test_entity_hidden_by_integration(hass: HomeAssistant) -> None: await component.async_add_entities([entity_default, entity_hidden]) - registry = er.async_get(hass) - - entry_default = registry.async_get_or_create(DOMAIN, DOMAIN, "default") + entry_default = entity_registry.async_get_or_create(DOMAIN, DOMAIN, "default") assert entry_default.hidden_by is None - entry_hidden = registry.async_get_or_create(DOMAIN, DOMAIN, "hidden") + entry_hidden = entity_registry.async_get_or_create(DOMAIN, DOMAIN, "hidden") assert entry_hidden.hidden_by is er.RegistryEntryHider.INTEGRATION -async def test_entity_info_added_to_entity_registry(hass: HomeAssistant) -> None: +async def test_entity_info_added_to_entity_registry( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test entity info is written to entity registry.""" component = EntityComponent(_LOGGER, DOMAIN, hass, timedelta(seconds=20)) await component.async_setup({}) @@ -1372,9 +1396,7 @@ async def test_entity_info_added_to_entity_registry(hass: HomeAssistant) -> None await component.async_add_entities([entity_default]) - registry = er.async_get(hass) - - entry_default = registry.async_get_or_create(DOMAIN, DOMAIN, "default") + entry_default = entity_registry.async_get_or_create(DOMAIN, DOMAIN, "default") assert entry_default == er.RegistryEntry( "test_domain.best_name", "default", @@ -1491,6 +1513,121 @@ async def test_platforms_sharing_services(hass: HomeAssistant) -> None: assert entity2 in entities +async def test_register_entity_service_response_data(hass: HomeAssistant) -> None: + """Test an entity service that does supports response data.""" + + async def generate_response( + target: MockEntity, call: ServiceCall + ) -> ServiceResponse: + assert call.return_response + return {"response-key": "response-value"} + + entity_platform = MockEntityPlatform( + hass, domain="mock_integration", platform_name="mock_platform", platform=None + ) + entity = MockEntity(entity_id="mock_integration.entity") + await entity_platform.async_add_entities([entity]) + + entity_platform.async_register_entity_service( + "hello", + {"some": str}, + generate_response, + supports_response=SupportsResponse.ONLY, + ) + + response_data = await hass.services.async_call( + "mock_platform", + "hello", + service_data={"some": "data"}, + target={"entity_id": [entity.entity_id]}, + blocking=True, + return_response=True, + ) + assert response_data == { + "mock_integration.entity": {"response-key": "response-value"} + } + + +async def test_register_entity_service_response_data_multiple_matches( + hass: HomeAssistant, +) -> None: + """Test an entity service that does supports response data and matching many entities.""" + + async def generate_response( + target: MockEntity, call: ServiceCall + ) -> ServiceResponse: + assert call.return_response + return {"response-key": f"response-value-{target.entity_id}"} + + entity_platform = MockEntityPlatform( + hass, domain="mock_integration", platform_name="mock_platform", platform=None + ) + entity1 = MockEntity(entity_id="mock_integration.entity1") + entity2 = MockEntity(entity_id="mock_integration.entity2") + await entity_platform.async_add_entities([entity1, entity2]) + + entity_platform.async_register_entity_service( + "hello", + {"some": str}, + generate_response, + supports_response=SupportsResponse.ONLY, + ) + + response_data = await hass.services.async_call( + "mock_platform", + "hello", + service_data={"some": "data"}, + target={"entity_id": [entity1.entity_id, entity2.entity_id]}, + blocking=True, + return_response=True, + ) + assert response_data == { + "mock_integration.entity1": { + "response-key": "response-value-mock_integration.entity1" + }, + "mock_integration.entity2": { + "response-key": "response-value-mock_integration.entity2" + }, + } + + +async def test_register_entity_service_response_data_multiple_matches_raises( + hass: HomeAssistant, +) -> None: + """Test entity service response matching many entities raises.""" + + async def generate_response( + target: MockEntity, call: ServiceCall + ) -> ServiceResponse: + assert call.return_response + if target.entity_id == "mock_integration.entity1": + raise RuntimeError("Something went wrong") + return {"response-key": f"response-value-{target.entity_id}"} + + entity_platform = MockEntityPlatform( + hass, domain="mock_integration", platform_name="mock_platform", platform=None + ) + entity1 = MockEntity(entity_id="mock_integration.entity1") + entity2 = MockEntity(entity_id="mock_integration.entity2") + await entity_platform.async_add_entities([entity1, entity2]) + + entity_platform.async_register_entity_service( + "hello", + {"some": str}, + generate_response, + supports_response=SupportsResponse.ONLY, + ) + with pytest.raises(RuntimeError, match="Something went wrong"): + await hass.services.async_call( + "mock_platform", + "hello", + service_data={"some": "data"}, + target={"entity_id": [entity1.entity_id, entity2.entity_id]}, + blocking=True, + return_response=True, + ) + + async def test_invalid_entity_id(hass: HomeAssistant) -> None: """Test specifying an invalid entity id.""" platform = MockEntityPlatform(hass) @@ -1523,16 +1660,16 @@ async def test_setup_entry_with_entities_that_block_forever( platform = MockPlatform(async_setup_entry=async_setup_entry) config_entry = MockConfigEntry(entry_id="super-mock-id") - mock_entity_platform = MockEntityPlatform( + platform = MockEntityPlatform( hass, platform_name=config_entry.domain, platform=platform ) with patch.object(entity_platform, "SLOW_ADD_ENTITY_MAX_WAIT", 0.01), patch.object( entity_platform, "SLOW_ADD_MIN_TIMEOUT", 0.01 ): - assert await mock_entity_platform.async_setup_entry(config_entry) + assert await platform.async_setup_entry(config_entry) await hass.async_block_till_done() - full_name = f"{mock_entity_platform.domain}.{config_entry.domain}" + full_name = f"{platform.domain}.{config_entry.domain}" assert full_name in hass.config.components assert len(hass.states.async_entity_ids()) == 0 assert len(entity_registry.entities) == 1 @@ -1599,12 +1736,12 @@ class SlowEntity(MockEntity): ) async def test_entity_name_influences_entity_id( hass: HomeAssistant, + entity_registry: er.EntityRegistry, has_entity_name: bool, entity_name: str | None, expected_entity_id: str, ) -> None: """Test entity_id is influenced by entity name.""" - registry = er.async_get(hass) async def async_setup_entry(hass, config_entry, async_add_entities): """Mock setup entry method.""" @@ -1635,7 +1772,7 @@ async def test_entity_name_influences_entity_id( await hass.async_block_till_done() assert len(hass.states.async_entity_ids()) == 1 - assert registry.async_get(expected_entity_id) is not None + assert entity_registry.async_get(expected_entity_id) is not None @pytest.mark.parametrize( @@ -1650,6 +1787,7 @@ async def test_entity_name_influences_entity_id( ) async def test_translated_entity_name_influences_entity_id( hass: HomeAssistant, + entity_registry: er.EntityRegistry, language: str, has_entity_name: bool, expected_entity_id: str, @@ -1670,8 +1808,6 @@ async def test_translated_entity_name_influences_entity_id( """Initialize.""" self._attr_has_entity_name = has_entity_name - registry = er.async_get(hass) - translations = { "en": {"component.test.entity.test_domain.test.name": "English name"}, "sv": {"component.test.entity.test_domain.test.name": "Swedish name"}, @@ -1709,7 +1845,7 @@ async def test_translated_entity_name_influences_entity_id( await hass.async_block_till_done() assert len(hass.states.async_entity_ids()) == 1 - assert registry.async_get(expected_entity_id) is not None + assert entity_registry.async_get(expected_entity_id) is not None @pytest.mark.parametrize( @@ -1730,6 +1866,7 @@ async def test_translated_entity_name_influences_entity_id( ) async def test_translated_device_class_name_influences_entity_id( hass: HomeAssistant, + entity_registry: er.EntityRegistry, language: str, has_entity_name: bool, device_class: str | None, @@ -1754,8 +1891,6 @@ async def test_translated_device_class_name_influences_entity_id( """Return True if an unnamed entity should be named by its device class.""" return self.device_class is not None - registry = er.async_get(hass) - translations = { "en": {"component.test_domain.entity_component.test_class.name": "English cls"}, "sv": {"component.test_domain.entity_component.test_class.name": "Swedish cls"}, @@ -1793,7 +1928,7 @@ async def test_translated_device_class_name_influences_entity_id( await hass.async_block_till_done() assert len(hass.states.async_entity_ids()) == 1 - assert registry.async_get(expected_entity_id) is not None + assert entity_registry.async_get(expected_entity_id) is not None @pytest.mark.parametrize( @@ -1812,6 +1947,7 @@ async def test_translated_device_class_name_influences_entity_id( ) async def test_device_name_defaulting_config_entry( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, config_entry_title: str, entity_device_name: str, entity_device_default_name: str, @@ -1846,8 +1982,9 @@ async def test_device_name_defaulting_config_entry( assert await entity_platform.async_setup_entry(config_entry) await hass.async_block_till_done() - dev_reg = dr.async_get(hass) - device = dev_reg.async_get_device(connections={(dr.CONNECTION_NETWORK_MAC, "1234")}) + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, "1234")} + ) assert device is not None assert device.name == expected_device_name @@ -1872,6 +2009,8 @@ async def test_device_name_defaulting_config_entry( ) async def test_device_type_error_checking( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, device_info: dict, number_of_entities: int, ) -> None: @@ -1897,8 +2036,6 @@ async def test_device_type_error_checking( assert await entity_platform.async_setup_entry(config_entry) - dev_reg = dr.async_get(hass) - assert len(dev_reg.devices) == 0 - ent_reg = er.async_get(hass) - assert len(ent_reg.entities) == number_of_entities + assert len(device_registry.devices) == 0 + assert len(entity_registry.entities) == number_of_entities assert len(hass.states.async_all()) == number_of_entities diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 95558e9c73d..d01d7746253 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -866,27 +866,27 @@ async def test_disabled_by_config_entry_pref( assert entry2.disabled_by is er.RegistryEntryDisabler.USER -async def test_restore_states(hass: HomeAssistant) -> None: +async def test_restore_states( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test restoring states.""" hass.state = CoreState.not_running - registry = er.async_get(hass) - - registry.async_get_or_create( + entity_registry.async_get_or_create( "light", "hue", "1234", suggested_object_id="simple", ) # Should not be created - registry.async_get_or_create( + entity_registry.async_get_or_create( "light", "hue", "5678", suggested_object_id="disabled", disabled_by=er.RegistryEntryDisabler.HASS, ) - registry.async_get_or_create( + entity_registry.async_get_or_create( "light", "hue", "9012", @@ -921,9 +921,9 @@ async def test_restore_states(hass: HomeAssistant) -> None: "icon": "hass:original-icon", } - registry.async_remove("light.disabled") - registry.async_remove("light.simple") - registry.async_remove("light.all_info_set") + entity_registry.async_remove("light.disabled") + entity_registry.async_remove("light.simple") + entity_registry.async_remove("light.all_info_set") await hass.async_block_till_done() @@ -932,58 +932,58 @@ async def test_restore_states(hass: HomeAssistant) -> None: assert hass.states.get("light.all_info_set") is None -async def test_async_get_device_class_lookup(hass: HomeAssistant) -> None: +async def test_async_get_device_class_lookup( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test registry device class lookup.""" hass.state = CoreState.not_running - ent_reg = er.async_get(hass) - - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "binary_sensor", "light", "battery_charging", device_id="light_device_entry_id", original_device_class="battery_charging", ) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "sensor", "light", "battery", device_id="light_device_entry_id", original_device_class="battery", ) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "light", "light", "demo", device_id="light_device_entry_id" ) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "binary_sensor", "vacuum", "battery_charging", device_id="vacuum_device_entry_id", original_device_class="battery_charging", ) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "sensor", "vacuum", "battery", device_id="vacuum_device_entry_id", original_device_class="battery", ) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "vacuum", "vacuum", "demo", device_id="vacuum_device_entry_id" ) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "binary_sensor", "remote", "battery_charging", device_id="remote_device_entry_id", original_device_class="battery_charging", ) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "remote", "remote", "demo", device_id="remote_device_entry_id" ) - device_lookup = ent_reg.async_get_device_class_lookup( + device_lookup = entity_registry.async_get_device_class_lookup( {("binary_sensor", "battery_charging"), ("sensor", "battery")} ) @@ -1476,50 +1476,52 @@ def test_entity_registry_items() -> None: assert entities.get_entry(entry2.id) is None -async def test_disabled_by_str_not_allowed(hass: HomeAssistant) -> None: +async def test_disabled_by_str_not_allowed( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test we need to pass disabled by type.""" - reg = er.async_get(hass) - with pytest.raises(ValueError): - reg.async_get_or_create( + entity_registry.async_get_or_create( "light", "hue", "1234", disabled_by=er.RegistryEntryDisabler.USER.value ) - entity_id = reg.async_get_or_create("light", "hue", "1234").entity_id + entity_id = entity_registry.async_get_or_create("light", "hue", "1234").entity_id with pytest.raises(ValueError): - reg.async_update_entity( + entity_registry.async_update_entity( entity_id, disabled_by=er.RegistryEntryDisabler.USER.value ) -async def test_entity_category_str_not_allowed(hass: HomeAssistant) -> None: +async def test_entity_category_str_not_allowed( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test we need to pass entity category type.""" - reg = er.async_get(hass) - with pytest.raises(ValueError): - reg.async_get_or_create( + entity_registry.async_get_or_create( "light", "hue", "1234", entity_category=EntityCategory.DIAGNOSTIC.value ) - entity_id = reg.async_get_or_create("light", "hue", "1234").entity_id + entity_id = entity_registry.async_get_or_create("light", "hue", "1234").entity_id with pytest.raises(ValueError): - reg.async_update_entity( + entity_registry.async_update_entity( entity_id, entity_category=EntityCategory.DIAGNOSTIC.value ) -async def test_hidden_by_str_not_allowed(hass: HomeAssistant) -> None: +async def test_hidden_by_str_not_allowed( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test we need to pass hidden by type.""" - reg = er.async_get(hass) - with pytest.raises(ValueError): - reg.async_get_or_create( + entity_registry.async_get_or_create( "light", "hue", "1234", hidden_by=er.RegistryEntryHider.USER.value ) - entity_id = reg.async_get_or_create("light", "hue", "1234").entity_id + entity_id = entity_registry.async_get_or_create("light", "hue", "1234").entity_id with pytest.raises(ValueError): - reg.async_update_entity(entity_id, hidden_by=er.RegistryEntryHider.USER.value) + entity_registry.async_update_entity( + entity_id, hidden_by=er.RegistryEntryHider.USER.value + ) def test_migrate_entity_to_new_platform( @@ -1595,34 +1597,35 @@ def test_migrate_entity_to_new_platform( ) -async def test_restore_entity(hass, update_events, freezer): +async def test_restore_entity( + hass: HomeAssistant, entity_registry: er.EntityRegistry, update_events, freezer +): """Make sure entity registry id is stable and entity_id is reused if possible.""" - registry = er.async_get(hass) # We need the real entity registry for this test config_entry = MockConfigEntry(domain="light") - entry1 = registry.async_get_or_create( + entry1 = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry ) - entry2 = registry.async_get_or_create( + entry2 = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=config_entry ) - entry1 = registry.async_update_entity( + entry1 = entity_registry.async_update_entity( entry1.entity_id, new_entity_id="light.custom_1" ) - registry.async_remove(entry1.entity_id) - registry.async_remove(entry2.entity_id) - assert len(registry.entities) == 0 - assert len(registry.deleted_entities) == 2 + entity_registry.async_remove(entry1.entity_id) + entity_registry.async_remove(entry2.entity_id) + assert len(entity_registry.entities) == 0 + assert len(entity_registry.deleted_entities) == 2 # Re-add entities - entry1_restored = registry.async_get_or_create( + entry1_restored = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry ) - entry2_restored = registry.async_get_or_create("light", "hue", "5678") + entry2_restored = entity_registry.async_get_or_create("light", "hue", "5678") - assert len(registry.entities) == 2 - assert len(registry.deleted_entities) == 0 + assert len(entity_registry.entities) == 2 + assert len(entity_registry.deleted_entities) == 0 assert entry1 != entry1_restored # entity_id is not restored assert attr.evolve(entry1, entity_id="light.hue_1234") == entry1_restored @@ -1631,39 +1634,39 @@ async def test_restore_entity(hass, update_events, freezer): assert attr.evolve(entry2, config_entry_id=None) == entry2_restored # Remove two of the entities again, then bump time - registry.async_remove(entry1_restored.entity_id) - registry.async_remove(entry2.entity_id) - assert len(registry.entities) == 0 - assert len(registry.deleted_entities) == 2 + entity_registry.async_remove(entry1_restored.entity_id) + entity_registry.async_remove(entry2.entity_id) + assert len(entity_registry.entities) == 0 + assert len(entity_registry.deleted_entities) == 2 freezer.tick(timedelta(seconds=er.ORPHANED_ENTITY_KEEP_SECONDS + 1)) async_fire_time_changed(hass) await hass.async_block_till_done() # Re-add two entities, expect to get a new id after the purge for entity w/o config entry - entry1_restored = registry.async_get_or_create( + entry1_restored = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry ) - entry2_restored = registry.async_get_or_create("light", "hue", "5678") - assert len(registry.entities) == 2 - assert len(registry.deleted_entities) == 0 + entry2_restored = entity_registry.async_get_or_create("light", "hue", "5678") + assert len(entity_registry.entities) == 2 + assert len(entity_registry.deleted_entities) == 0 assert entry1.id == entry1_restored.id assert entry2.id != entry2_restored.id # Remove the first entity, then its config entry, finally bump time - registry.async_remove(entry1_restored.entity_id) - assert len(registry.entities) == 1 - assert len(registry.deleted_entities) == 1 - registry.async_clear_config_entry(config_entry.entry_id) + entity_registry.async_remove(entry1_restored.entity_id) + assert len(entity_registry.entities) == 1 + assert len(entity_registry.deleted_entities) == 1 + entity_registry.async_clear_config_entry(config_entry.entry_id) freezer.tick(timedelta(seconds=er.ORPHANED_ENTITY_KEEP_SECONDS + 1)) async_fire_time_changed(hass) await hass.async_block_till_done() # Re-add the entity, expect to get a new id after the purge - entry1_restored = registry.async_get_or_create( + entry1_restored = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry ) - assert len(registry.entities) == 2 - assert len(registry.deleted_entities) == 0 + assert len(entity_registry.entities) == 2 + assert len(entity_registry.deleted_entities) == 0 assert entry1.id != entry1_restored.id # Check the events @@ -1687,18 +1690,19 @@ async def test_restore_entity(hass, update_events, freezer): assert update_events[12] == {"action": "create", "entity_id": "light.hue_1234"} -async def test_async_migrate_entry_delete_self(hass): +async def test_async_migrate_entry_delete_self( + hass: HomeAssistant, entity_registry: er.EntityRegistry +): """Test async_migrate_entry.""" - registry = er.async_get(hass) config_entry1 = MockConfigEntry(domain="test1") config_entry2 = MockConfigEntry(domain="test2") - entry1 = registry.async_get_or_create( + entry1 = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry1, original_name="Entry 1" ) - entry2 = registry.async_get_or_create( + entry2 = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=config_entry1, original_name="Entry 2" ) - entry3 = registry.async_get_or_create( + entry3 = entity_registry.async_get_or_create( "light", "hue", "90AB", config_entry=config_entry2, original_name="Entry 3" ) @@ -1706,7 +1710,7 @@ async def test_async_migrate_entry_delete_self(hass): def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, Any] | None: entries.add(entity_entry.entity_id) if entity_entry == entry1: - registry.async_remove(entry1.entity_id) + entity_registry.async_remove(entry1.entity_id) return None if entity_entry == entry2: return {"original_name": "Entry 2 renamed"} @@ -1715,24 +1719,25 @@ async def test_async_migrate_entry_delete_self(hass): entries = set() await er.async_migrate_entries(hass, config_entry1.entry_id, _async_migrator) assert entries == {entry1.entity_id, entry2.entity_id} - assert not registry.async_is_registered(entry1.entity_id) - entry2 = registry.async_get(entry2.entity_id) + assert not entity_registry.async_is_registered(entry1.entity_id) + entry2 = entity_registry.async_get(entry2.entity_id) assert entry2.original_name == "Entry 2 renamed" - assert registry.async_get(entry3.entity_id) is entry3 + assert entity_registry.async_get(entry3.entity_id) is entry3 -async def test_async_migrate_entry_delete_other(hass): +async def test_async_migrate_entry_delete_other( + hass: HomeAssistant, entity_registry: er.EntityRegistry +): """Test async_migrate_entry.""" - registry = er.async_get(hass) config_entry1 = MockConfigEntry(domain="test1") config_entry2 = MockConfigEntry(domain="test2") - entry1 = registry.async_get_or_create( + entry1 = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry1, original_name="Entry 1" ) - entry2 = registry.async_get_or_create( + entry2 = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=config_entry1, original_name="Entry 2" ) - registry.async_get_or_create( + entity_registry.async_get_or_create( "light", "hue", "90AB", config_entry=config_entry2, original_name="Entry 3" ) @@ -1740,7 +1745,7 @@ async def test_async_migrate_entry_delete_other(hass): def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, Any] | None: entries.add(entity_entry.entity_id) if entity_entry == entry1: - registry.async_remove(entry2.entity_id) + entity_registry.async_remove(entry2.entity_id) return None if entity_entry == entry2: # We should not get here @@ -1750,4 +1755,4 @@ async def test_async_migrate_entry_delete_other(hass): entries = set() await er.async_migrate_entries(hass, config_entry1.entry_id, _async_migrator) assert entries == {entry1.entity_id} - assert not registry.async_is_registered(entry2.entity_id) + assert not entity_registry.async_is_registered(entry2.entity_id) diff --git a/tests/helpers/test_reload.py b/tests/helpers/test_reload.py index ad3b7ccb243..586dbc19eb8 100644 --- a/tests/helpers/test_reload.py +++ b/tests/helpers/test_reload.py @@ -3,10 +3,12 @@ import logging from unittest.mock import AsyncMock, Mock, patch import pytest +import voluptuous as vol from homeassistant import config from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigValidationError, HomeAssistantError from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.reload import ( @@ -21,8 +23,8 @@ from tests.common import ( MockModule, MockPlatform, get_fixture_path, - mock_entity_platform, mock_integration, + mock_platform, ) _LOGGER = logging.getLogger(__name__) @@ -42,8 +44,8 @@ async def test_reload_platform(hass: HomeAssistant) -> None: mock_integration(hass, MockModule(DOMAIN, setup=component_setup)) mock_integration(hass, MockModule(PLATFORM, dependencies=[DOMAIN])) - mock_platform = MockPlatform(async_setup_platform=setup_platform) - mock_entity_platform(hass, f"{DOMAIN}.{PLATFORM}", mock_platform) + platform = MockPlatform(async_setup_platform=setup_platform) + mock_platform(hass, f"{PLATFORM}.{DOMAIN}", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -82,8 +84,8 @@ async def test_setup_reload_service(hass: HomeAssistant) -> None: mock_integration(hass, MockModule(DOMAIN, setup=component_setup)) mock_integration(hass, MockModule(PLATFORM, dependencies=[DOMAIN])) - mock_platform = MockPlatform(async_setup_platform=setup_platform) - mock_entity_platform(hass, f"{DOMAIN}.{PLATFORM}", mock_platform) + platform = MockPlatform(async_setup_platform=setup_platform) + mock_platform(hass, f"{PLATFORM}.{DOMAIN}", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -123,8 +125,8 @@ async def test_setup_reload_service_when_async_process_component_config_fails( mock_integration(hass, MockModule(DOMAIN, setup=component_setup)) mock_integration(hass, MockModule(PLATFORM, dependencies=[DOMAIN])) - mock_platform = MockPlatform(async_setup_platform=setup_platform) - mock_entity_platform(hass, f"{DOMAIN}.{PLATFORM}", mock_platform) + platform = MockPlatform(async_setup_platform=setup_platform) + mock_platform(hass, f"{PLATFORM}.{DOMAIN}", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -139,7 +141,9 @@ async def test_setup_reload_service_when_async_process_component_config_fails( yaml_path = get_fixture_path("helpers/reload_configuration.yaml") with patch.object(config, "YAML_CONFIG_FILE", yaml_path), patch.object( - config, "async_process_component_config", return_value=None + config, + "async_process_component_config", + return_value=config.IntegrationConfigInfo(None, []), ): await hass.services.async_call( PLATFORM, @@ -173,8 +177,8 @@ async def test_setup_reload_service_with_platform_that_provides_async_reset_plat mock_integration(hass, MockModule(PLATFORM, dependencies=[DOMAIN])) - mock_platform = MockPlatform(async_setup_platform=setup_platform) - mock_entity_platform(hass, f"{DOMAIN}.{PLATFORM}", mock_platform) + platform = MockPlatform(async_setup_platform=setup_platform) + mock_platform(hass, f"{PLATFORM}.{DOMAIN}", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -208,8 +212,49 @@ async def test_async_integration_yaml_config(hass: HomeAssistant) -> None: yaml_path = get_fixture_path(f"helpers/{DOMAIN}_configuration.yaml") with patch.object(config, "YAML_CONFIG_FILE", yaml_path): processed_config = await async_integration_yaml_config(hass, DOMAIN) + assert processed_config == {DOMAIN: [{"name": "one"}, {"name": "two"}]} + # Test fetching yaml config does not raise when the raise_on_failure option is set + processed_config = await async_integration_yaml_config( + hass, DOMAIN, raise_on_failure=True + ) + assert processed_config == {DOMAIN: [{"name": "one"}, {"name": "two"}]} - assert processed_config == {DOMAIN: [{"name": "one"}, {"name": "two"}]} + +async def test_async_integration_failing_yaml_config(hass: HomeAssistant) -> None: + """Test reloading yaml config for an integration fails. + + In case an integration reloads its yaml configuration it should throw when + the new config failed to load and raise_on_failure is set to True. + """ + schema_without_name_attr = vol.Schema({vol.Required("some_option"): str}) + + mock_integration(hass, MockModule(DOMAIN, config_schema=schema_without_name_attr)) + + yaml_path = get_fixture_path(f"helpers/{DOMAIN}_configuration.yaml") + with patch.object(config, "YAML_CONFIG_FILE", yaml_path): + # Test fetching yaml config does not raise without raise_on_failure option + processed_config = await async_integration_yaml_config(hass, DOMAIN) + assert processed_config is None + # Test fetching yaml config does not raise when the raise_on_failure option is set + with pytest.raises(ConfigValidationError): + await async_integration_yaml_config(hass, DOMAIN, raise_on_failure=True) + + +async def test_async_integration_failing_on_reload(hass: HomeAssistant) -> None: + """Test reloading yaml config for an integration fails with an other exception. + + In case an integration reloads its yaml configuration it should throw when + the new config failed to load and raise_on_failure is set to True. + """ + mock_integration(hass, MockModule(DOMAIN)) + + yaml_path = get_fixture_path(f"helpers/{DOMAIN}_configuration.yaml") + with patch.object(config, "YAML_CONFIG_FILE", yaml_path), patch( + "homeassistant.config.async_process_component_config", + side_effect=HomeAssistantError(), + ), pytest.raises(HomeAssistantError): + # Test fetching yaml config does raise when the raise_on_failure option is set + await async_integration_yaml_config(hass, DOMAIN, raise_on_failure=True) async def test_async_integration_missing_yaml_config(hass: HomeAssistant) -> None: diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index fa0a14b8fbb..f01718d6af6 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -31,8 +31,8 @@ from tests.common import ( MockModule, MockPlatform, async_fire_time_changed, - mock_entity_platform, mock_integration, + mock_platform, ) _LOGGER = logging.getLogger(__name__) @@ -499,8 +499,8 @@ async def test_restore_entity_end_to_end( mock_integration(hass, MockModule(DOMAIN, setup=component_setup)) mock_integration(hass, MockModule(PLATFORM, dependencies=[DOMAIN])) - mock_platform = MockPlatform(async_setup_platform=async_setup_platform) - mock_entity_platform(hass, f"{DOMAIN}.{PLATFORM}", mock_platform) + platform = MockPlatform(async_setup_platform=async_setup_platform) + mock_platform(hass, f"{PLATFORM}.{DOMAIN}", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) diff --git a/tests/helpers/test_schema_config_entry_flow.py b/tests/helpers/test_schema_config_entry_flow.py index 7954b63b241..58f6a261aef 100644 --- a/tests/helpers/test_schema_config_entry_flow.py +++ b/tests/helpers/test_schema_config_entry_flow.py @@ -23,13 +23,7 @@ from homeassistant.helpers.schema_config_entry_flow import ( ) from homeassistant.util.decorator import Registry -from tests.common import ( - MockConfigEntry, - MockModule, - mock_entity_platform, - mock_integration, - mock_platform, -) +from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform TEST_DOMAIN = "test" @@ -78,9 +72,8 @@ def manager_fixture(): return mgr -async def test_name(hass: HomeAssistant) -> None: +async def test_name(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test the config flow name is copied from registry entry, with fallback to state.""" - registry = er.async_get(hass) entity_id = "switch.ceiling" # No entry or state, use Object ID @@ -92,7 +85,7 @@ async def test_name(hass: HomeAssistant) -> None: # Entity registered, use original name from registry entry hass.states.async_remove(entity_id) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( "switch", "test", "unique", @@ -105,7 +98,7 @@ async def test_name(hass: HomeAssistant) -> None: assert wrapped_entity_config_entry_title(hass, entry.id) == "Original Name" # Entity has customized name - registry.async_update_entity("switch.ceiling", name="Custom Name") + entity_registry.async_update_entity("switch.ceiling", name="Custom Name") assert wrapped_entity_config_entry_title(hass, entity_id) == "Custom Name" assert wrapped_entity_config_entry_title(hass, entry.id) == "Custom Name" @@ -233,7 +226,7 @@ async def test_options_flow_advanced_option( options_flow = OPTIONS_FLOW mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) config_entry = MockConfigEntry( data={}, domain="test", @@ -522,7 +515,7 @@ async def test_suggested_values( options_flow = OPTIONS_FLOW mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) config_entry = MockConfigEntry( data={}, domain="test", @@ -635,7 +628,7 @@ async def test_options_flow_state(hass: HomeAssistant) -> None: options_flow = OPTIONS_FLOW mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) config_entry = MockConfigEntry( data={}, domain="test", @@ -701,7 +694,7 @@ async def test_options_flow_omit_optional_keys( options_flow = OPTIONS_FLOW mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) config_entry = MockConfigEntry( data={}, domain="test", diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 6c327345881..7e655a69c0a 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -1788,11 +1788,12 @@ async def test_shorthand_template_condition( async def test_condition_validation( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if we can use conditions which validate late in a script.""" - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( "test", "hue", "1234", suggested_object_id="entity" ) assert entry.entity_id == "test.entity" @@ -2385,11 +2386,12 @@ async def test_repeat_conditional( async def test_repeat_until_condition_validation( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if we can use conditions in repeat until conditions which validate late.""" - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( "test", "hue", "1234", suggested_object_id="entity" ) assert entry.entity_id == "test.entity" @@ -2447,11 +2449,12 @@ async def test_repeat_until_condition_validation( async def test_repeat_while_condition_validation( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if we can use conditions in repeat while conditions which validate late.""" - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( "test", "hue", "1234", suggested_object_id="entity" ) assert entry.entity_id == "test.entity" @@ -2868,11 +2871,12 @@ async def test_choose( async def test_choose_condition_validation( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if we can use conditions in choose actions which validate late.""" - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( "test", "hue", "1234", suggested_object_id="entity" ) assert entry.entity_id == "test.entity" @@ -3112,11 +3116,12 @@ async def test_if_disabled( async def test_if_condition_validation( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if we can use conditions in if actions which validate late.""" - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( "test", "hue", "1234", suggested_object_id="entity" ) assert entry.entity_id == "test.entity" diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index ee4749be346..c4ad244620b 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -602,6 +602,11 @@ def test_object_selector_schema(schema, valid_selections, invalid_selections) -> ({"multiline": True}, (), ()), ({"multiline": False, "type": "email"}, (), ()), ({"prefix": "before", "suffix": "after"}, (), ()), + ( + {"multiple": True}, + (["abc123", "def456"],), + ("abc123", None, ["abc123", None]), + ), ), ) def test_text_selector_schema(schema, valid_selections, invalid_selections) -> None: @@ -907,6 +912,16 @@ def test_rgb_color_selector_schema( (100, 200), (99, 201), ), + ( + {"unit": "mired", "min": 100, "max": 200}, + (100, 200), + (99, 201), + ), + ( + {"unit": "kelvin", "min": 1000, "max": 2000}, + (1000, 2000), + (999, 2001), + ), ), ) def test_color_tempselector_schema( @@ -1074,3 +1089,27 @@ def test_condition_selector_schema( ) -> None: """Test condition sequence selector.""" _test_selector("condition", schema, valid_selections, invalid_selections) + + +@pytest.mark.parametrize( + ("schema", "valid_selections", "invalid_selections"), + ( + ( + {}, + ( + [ + { + "platform": "numeric_state", + "entity_id": ["sensor.temperature"], + "below": 20, + } + ], + [], + ), + ("abc"), + ), + ), +) +def test_trigger_selector_schema(schema, valid_selections, invalid_selections) -> None: + """Test trigger sequence selector.""" + _test_selector("trigger", schema, valid_selections, invalid_selections) diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 03a8b5e11b2..04324cdbfa3 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -465,7 +465,14 @@ async def test_extract_entity_ids(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) await hass.async_block_till_done() await hass.components.group.Group.async_create_group( - hass, "test", ["light.Ceiling", "light.Kitchen"] + hass, + "test", + created_by_service=False, + entity_ids=["light.Ceiling", "light.Kitchen"], + icon=None, + mode=None, + object_id=None, + order=None, ) call = ServiceCall("light", "turn_on", {ATTR_ENTITY_ID: "light.Bowl"}) diff --git a/tests/helpers/test_system_info.py b/tests/helpers/test_system_info.py index ebb0cc35c20..5c3697ad936 100644 --- a/tests/helpers/test_system_info.py +++ b/tests/helpers/test_system_info.py @@ -38,13 +38,9 @@ async def test_get_system_info_supervisor_not_available( "homeassistant.helpers.system_info.is_docker_env", return_value=True ), patch( "homeassistant.helpers.system_info.is_official_image", return_value=True - ), patch( - "homeassistant.components.hassio.is_hassio", return_value=True - ), patch( + ), patch("homeassistant.components.hassio.is_hassio", return_value=True), patch( "homeassistant.components.hassio.get_info", return_value=None - ), patch( - "homeassistant.helpers.system_info.cached_get_user", return_value="root" - ): + ), patch("homeassistant.helpers.system_info.cached_get_user", return_value="root"): info = await async_get_system_info(hass) assert isinstance(info, dict) assert info["version"] == current_version @@ -60,9 +56,7 @@ async def test_get_system_info_supervisor_not_loaded(hass: HomeAssistant) -> Non "homeassistant.helpers.system_info.is_docker_env", return_value=True ), patch( "homeassistant.helpers.system_info.is_official_image", return_value=True - ), patch( - "homeassistant.components.hassio.get_info", return_value=None - ), patch.dict( + ), patch("homeassistant.components.hassio.get_info", return_value=None), patch.dict( os.environ, {"SUPERVISOR": "127.0.0.1"} ): info = await async_get_system_info(hass) @@ -79,9 +73,7 @@ async def test_container_installationtype(hass: HomeAssistant) -> None: "homeassistant.helpers.system_info.is_docker_env", return_value=True ), patch( "homeassistant.helpers.system_info.is_official_image", return_value=True - ), patch( - "homeassistant.helpers.system_info.cached_get_user", return_value="root" - ): + ), patch("homeassistant.helpers.system_info.cached_get_user", return_value="root"): info = await async_get_system_info(hass) assert info["installation_type"] == "Home Assistant Container" @@ -89,9 +81,7 @@ async def test_container_installationtype(hass: HomeAssistant) -> None: "homeassistant.helpers.system_info.is_docker_env", return_value=True ), patch( "homeassistant.helpers.system_info.is_official_image", return_value=False - ), patch( - "homeassistant.helpers.system_info.cached_get_user", return_value="user" - ): + ), patch("homeassistant.helpers.system_info.cached_get_user", return_value="user"): info = await async_get_system_info(hass) assert info["installation_type"] == "Unsupported Third Party Container" diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index c466bfed213..79358ec588d 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -2649,7 +2649,16 @@ async def test_closest_function_home_vs_group_entity_id(hass: HomeAssistant) -> assert await async_setup_component(hass, "group", {}) await hass.async_block_till_done() - await group.Group.async_create_group(hass, "location group", ["test_domain.object"]) + await group.Group.async_create_group( + hass, + "location group", + created_by_service=False, + entity_ids=["test_domain.object"], + icon=None, + mode=None, + object_id=None, + order=None, + ) info = render_to_info(hass, '{{ closest("group.location_group").entity_id }}') assert_result_info( @@ -2677,7 +2686,16 @@ async def test_closest_function_home_vs_group_state(hass: HomeAssistant) -> None assert await async_setup_component(hass, "group", {}) await hass.async_block_till_done() - await group.Group.async_create_group(hass, "location group", ["test_domain.object"]) + await group.Group.async_create_group( + hass, + "location group", + created_by_service=False, + entity_ids=["test_domain.object"], + icon=None, + mode=None, + object_id=None, + order=None, + ) info = render_to_info(hass, '{{ closest("group.location_group").entity_id }}') assert_result_info( @@ -2727,7 +2745,16 @@ async def test_expand(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) await hass.async_block_till_done() - await group.Group.async_create_group(hass, "new group", ["test.object"]) + await group.Group.async_create_group( + hass, + "new group", + created_by_service=False, + entity_ids=["test.object"], + icon=None, + mode=None, + object_id=None, + order=None, + ) info = render_to_info( hass, @@ -2769,7 +2796,14 @@ async def test_expand(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) await hass.async_block_till_done() await group.Group.async_create_group( - hass, "power sensors", ["sensor.power_1", "sensor.power_2", "sensor.power_3"] + hass, + "power sensors", + created_by_service=False, + entity_ids=["sensor.power_1", "sensor.power_2", "sensor.power_3"], + icon=None, + mode=None, + object_id=None, + order=None, ) info = render_to_info( @@ -4046,9 +4080,10 @@ def test_state_with_unit(hass: HomeAssistant) -> None: assert tpl.async_render() == "" -def test_state_with_unit_and_rounding(hass: HomeAssistant) -> None: +def test_state_with_unit_and_rounding( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test formatting the state rounded and with unit.""" - entity_registry = er.async_get(hass) entry = entity_registry.async_get_or_create( "sensor", "test", "very_unique", suggested_object_id="test" ) @@ -4119,6 +4154,7 @@ def test_state_with_unit_and_rounding(hass: HomeAssistant) -> None: ) def test_state_with_unit_and_rounding_options( hass: HomeAssistant, + entity_registry: er.EntityRegistry, rounded: str, with_unit: str, output1_1, @@ -4127,7 +4163,6 @@ def test_state_with_unit_and_rounding_options( output2_2, ) -> None: """Test formatting the state rounded and with unit.""" - entity_registry = er.async_get(hass) entry = entity_registry.async_get_or_create( "sensor", "test", "very_unique", suggested_object_id="test" ) diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index e410dd672ce..06dff1e0869 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -49,6 +49,7 @@ def test_bad_core_config(mock_is_file, event_loop, mock_hass_config_yaml: None) res = check_config.check(get_test_config_dir()) assert res["except"].keys() == {"homeassistant"} assert res["except"]["homeassistant"][1] == {"unit_system": "bad"} + assert res["warn"] == {} @pytest.mark.parametrize("hass_config_yaml", [BASE_CONFIG + "light:\n platform: demo"]) @@ -62,6 +63,7 @@ def test_config_platform_valid( assert res["except"] == {} assert res["secret_cache"] == {} assert res["secrets"] == {} + assert res["warn"] == {} assert len(res["yaml_files"]) == 1 @@ -87,9 +89,10 @@ def test_component_platform_not_found( # Make sure they don't exist res = check_config.check(get_test_config_dir()) assert res["components"].keys() == platforms - assert res["except"] == {check_config.ERROR_STR: [error]} + assert res["except"] == {} assert res["secret_cache"] == {} assert res["secrets"] == {} + assert res["warn"] == {check_config.WARNING_STR: [error]} assert len(res["yaml_files"]) == 1 @@ -123,6 +126,7 @@ def test_secrets(mock_is_file, event_loop, mock_hass_config_yaml: None) -> None: get_test_config_dir("secrets.yaml"): {"http_pw": "http://google.com"} } assert res["secrets"] == {"http_pw": "http://google.com"} + assert res["warn"] == {} assert normalize_yaml_files(res) == [ ".../configuration.yaml", ".../secrets.yaml", @@ -136,13 +140,12 @@ def test_package_invalid(mock_is_file, event_loop, mock_hass_config_yaml: None) """Test an invalid package.""" res = check_config.check(get_test_config_dir()) - assert res["except"].keys() == {"homeassistant.packages.p1.group"} - assert res["except"]["homeassistant.packages.p1.group"][1] == {"group": ["a"]} - assert len(res["except"]) == 1 + assert res["except"] == {} assert res["components"].keys() == {"homeassistant"} - assert len(res["components"]) == 1 assert res["secret_cache"] == {} assert res["secrets"] == {} + assert res["warn"].keys() == {"homeassistant.packages.p1.group"} + assert res["warn"]["homeassistant.packages.p1.group"][1] == {"group": ["a"]} assert len(res["yaml_files"]) == 1 @@ -158,4 +161,5 @@ def test_bootstrap_error(event_loop, mock_hass_config_yaml: None) -> None: assert res["components"] == {} # No components, load failed assert res["secret_cache"] == {} assert res["secrets"] == {} + assert res["warn"] == {} assert res["yaml_files"] == {} diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr new file mode 100644 index 00000000000..7438bda5cde --- /dev/null +++ b/tests/snapshots/test_config.ambr @@ -0,0 +1,402 @@ +# serializer version: 1 +# name: test_component_config_validation_error[basic] + list([ + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'iot_domain' at configuration.yaml, line 6: required key 'platform' not provided", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 18: required key 'option1' not provided + Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 19: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option + Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123 + ''', + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'adr_0007_2' at configuration.yaml, line 27: required key 'host' not provided", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'adr_0007_3' at configuration.yaml, line 32: expected int for dictionary value 'adr_0007_3->port', got 'foo'", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'adr_0007_4' at configuration.yaml, line 37: 'no_such_option' is an invalid option for 'adr_0007_4', check: adr_0007_4->no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for 'adr_0007_5' at configuration.yaml, line 43: required key 'host' not provided + Invalid config for 'adr_0007_5' at configuration.yaml, line 44: 'no_such_option' is an invalid option for 'adr_0007_5', check: adr_0007_5->no_such_option + Invalid config for 'adr_0007_5' at configuration.yaml, line 45: expected int for dictionary value 'adr_0007_5->port', got 'foo' + ''', + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'custom_validator_ok_2' at configuration.yaml, line 52: required key 'host' not provided", + }), + dict({ + 'has_exc_info': True, + 'message': "Invalid config for 'custom_validator_bad_1' at configuration.yaml, line 55: broken", + }), + dict({ + 'has_exc_info': True, + 'message': 'Unknown error calling custom_validator_bad_2 config validator', + }), + ]) +# --- +# name: test_component_config_validation_error[basic_include] + list([ + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'iot_domain' at integrations/iot_domain.yaml, line 5: required key 'platform' not provided", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 8: expected str for dictionary value 'option1', got 123", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 11: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 17: required key 'option1' not provided + Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 18: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option + Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 19: expected str for dictionary value 'option2', got 123 + ''', + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'adr_0007_2' at configuration.yaml, line 3: required key 'host' not provided", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'adr_0007_3' at integrations/adr_0007_3.yaml, line 3: expected int for dictionary value 'adr_0007_3->port', got 'foo'", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'adr_0007_4' at integrations/adr_0007_4.yaml, line 3: 'no_such_option' is an invalid option for 'adr_0007_4', check: adr_0007_4->no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for 'adr_0007_5' at configuration.yaml, line 6: required key 'host' not provided + Invalid config for 'adr_0007_5' at integrations/adr_0007_5.yaml, line 5: 'no_such_option' is an invalid option for 'adr_0007_5', check: adr_0007_5->no_such_option + Invalid config for 'adr_0007_5' at integrations/adr_0007_5.yaml, line 6: expected int for dictionary value 'adr_0007_5->port', got 'foo' + ''', + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'custom_validator_ok_2' at configuration.yaml, line 8: required key 'host' not provided", + }), + dict({ + 'has_exc_info': True, + 'message': "Invalid config for 'custom_validator_bad_1' at configuration.yaml, line 9: broken", + }), + dict({ + 'has_exc_info': True, + 'message': 'Unknown error calling custom_validator_bad_2 config validator', + }), + ]) +# --- +# name: test_component_config_validation_error[include_dir_list] + list([ + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'iot_domain' at iot_domain/iot_domain_2.yaml, line 2: required key 'platform' not provided", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_3.yaml, line 3: expected str for dictionary value 'option1', got 123", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_4.yaml, line 3: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_5.yaml, line 5: required key 'option1' not provided + Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_5.yaml, line 6: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option + Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_5.yaml, line 7: expected str for dictionary value 'option2', got 123 + ''', + }), + ]) +# --- +# name: test_component_config_validation_error[include_dir_merge_list] + list([ + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'iot_domain' at iot_domain/iot_domain_1.yaml, line 5: required key 'platform' not provided", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value 'option1', got 123", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 6: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 12: required key 'option1' not provided + Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 13: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option + Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 14: expected str for dictionary value 'option2', got 123 + ''', + }), + ]) +# --- +# name: test_component_config_validation_error[packages] + list([ + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'iot_domain' at configuration.yaml, line 11: required key 'platform' not provided", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 16: expected str for dictionary value 'option1', got 123", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 21: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 29: required key 'option1' not provided + Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 30: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option + Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 31: expected str for dictionary value 'option2', got 123 + ''', + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'adr_0007_2' at configuration.yaml, line 38: required key 'host' not provided", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'adr_0007_3' at configuration.yaml, line 43: expected int for dictionary value 'adr_0007_3->port', got 'foo'", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'adr_0007_4' at configuration.yaml, line 48: 'no_such_option' is an invalid option for 'adr_0007_4', check: adr_0007_4->no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for 'adr_0007_5' at configuration.yaml, line 54: required key 'host' not provided + Invalid config for 'adr_0007_5' at configuration.yaml, line 55: 'no_such_option' is an invalid option for 'adr_0007_5', check: adr_0007_5->no_such_option + Invalid config for 'adr_0007_5' at configuration.yaml, line 56: expected int for dictionary value 'adr_0007_5->port', got 'foo' + ''', + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'custom_validator_ok_2' at configuration.yaml, line 64: required key 'host' not provided", + }), + dict({ + 'has_exc_info': True, + 'message': "Invalid config for 'custom_validator_bad_1' at configuration.yaml, line 67: broken", + }), + dict({ + 'has_exc_info': True, + 'message': 'Unknown error calling custom_validator_bad_2 config validator', + }), + ]) +# --- +# name: test_component_config_validation_error[packages_include_dir_named] + list([ + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'adr_0007_2' at integrations/adr_0007_2.yaml, line 2: required key 'host' not provided", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'adr_0007_3' at integrations/adr_0007_3.yaml, line 4: expected int for dictionary value 'adr_0007_3->port', got 'foo'", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'adr_0007_4' at integrations/adr_0007_4.yaml, line 4: 'no_such_option' is an invalid option for 'adr_0007_4', check: adr_0007_4->no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for 'adr_0007_5' at integrations/adr_0007_5.yaml, line 5: required key 'host' not provided + Invalid config for 'adr_0007_5' at integrations/adr_0007_5.yaml, line 6: 'no_such_option' is an invalid option for 'adr_0007_5', check: adr_0007_5->no_such_option + Invalid config for 'adr_0007_5' at integrations/adr_0007_5.yaml, line 7: expected int for dictionary value 'adr_0007_5->port', got 'foo' + ''', + }), + dict({ + 'has_exc_info': True, + 'message': "Invalid config for 'custom_validator_bad_1' at integrations/custom_validator_bad_1.yaml, line 2: broken", + }), + dict({ + 'has_exc_info': True, + 'message': 'Unknown error calling custom_validator_bad_2 config validator', + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'custom_validator_ok_2' at integrations/custom_validator_ok_2.yaml, line 2: required key 'host' not provided", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'iot_domain' at integrations/iot_domain.yaml, line 6: required key 'platform' not provided", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 9: expected str for dictionary value 'option1', got 123", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 12: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + }), + dict({ + 'has_exc_info': False, + 'message': ''' + Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 18: required key 'option1' not provided + Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 19: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option + Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 20: expected str for dictionary value 'option2', got 123 + ''', + }), + ]) +# --- +# name: test_component_config_validation_error_with_docs[basic] + list([ + "Invalid config for 'iot_domain' at configuration.yaml, line 6: required key 'platform' not provided, please check the docs at https://www.home-assistant.io/integrations/iot_domain", + "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", + "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", + ''' + Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 18: required key 'option1' not provided, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 + Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 19: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 + Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 + ''', + "Invalid config for 'adr_0007_2' at configuration.yaml, line 27: required key 'host' not provided, please check the docs at https://www.home-assistant.io/integrations/adr_0007_2", + "Invalid config for 'adr_0007_3' at configuration.yaml, line 32: expected int for dictionary value 'adr_0007_3->port', got 'foo', please check the docs at https://www.home-assistant.io/integrations/adr_0007_3", + "Invalid config for 'adr_0007_4' at configuration.yaml, line 37: 'no_such_option' is an invalid option for 'adr_0007_4', check: adr_0007_4->no_such_option, please check the docs at https://www.home-assistant.io/integrations/adr_0007_4", + ''' + Invalid config for 'adr_0007_5' at configuration.yaml, line 43: required key 'host' not provided, please check the docs at https://www.home-assistant.io/integrations/adr_0007_5 + Invalid config for 'adr_0007_5' at configuration.yaml, line 44: 'no_such_option' is an invalid option for 'adr_0007_5', check: adr_0007_5->no_such_option, please check the docs at https://www.home-assistant.io/integrations/adr_0007_5 + Invalid config for 'adr_0007_5' at configuration.yaml, line 45: expected int for dictionary value 'adr_0007_5->port', got 'foo', please check the docs at https://www.home-assistant.io/integrations/adr_0007_5 + ''', + "Invalid config for 'custom_validator_ok_2' at configuration.yaml, line 52: required key 'host' not provided, please check the docs at https://www.home-assistant.io/integrations/custom_validator_ok_2", + "Invalid config for 'custom_validator_bad_1' at configuration.yaml, line 55: broken, please check the docs at https://www.home-assistant.io/integrations/custom_validator_bad_1", + 'Unknown error calling custom_validator_bad_2 config validator', + ]) +# --- +# name: test_package_merge_error[packages] + list([ + "Setup of package 'pack_1' at configuration.yaml, line 7 failed: integration 'adr_0007_1' cannot be merged, dict expected in main config", + "Setup of package 'pack_2' at configuration.yaml, line 11 failed: integration 'adr_0007_2' cannot be merged, expected a dict", + "Setup of package 'pack_4' at configuration.yaml, line 19 failed: integration 'adr_0007_3' has duplicate key 'host'", + "Setup of package 'pack_5' at configuration.yaml, line 22 failed: Integration 'unknown_integration' not found.", + ]) +# --- +# name: test_package_merge_error[packages_include_dir_named] + list([ + "Setup of package 'adr_0007_1' at integrations/adr_0007_1.yaml, line 2 failed: integration 'adr_0007_1' cannot be merged, dict expected in main config", + "Setup of package 'adr_0007_2' at integrations/adr_0007_2.yaml, line 2 failed: integration 'adr_0007_2' cannot be merged, expected a dict", + "Setup of package 'adr_0007_3_2' at integrations/adr_0007_3_2.yaml, line 1 failed: integration 'adr_0007_3' has duplicate key 'host'", + "Setup of package 'unknown_integration' at integrations/unknown_integration.yaml, line 2 failed: Integration 'unknown_integration' not found.", + ]) +# --- +# name: test_package_merge_exception[packages-error0] + list([ + "Setup of package 'pack_1' at configuration.yaml, line 3 failed: Integration test_domain caused error: No such file or directory: b'liblibc.a'", + ]) +# --- +# name: test_package_merge_exception[packages-error1] + list([ + "Setup of package 'pack_1' at configuration.yaml, line 3 failed: Integration test_domain caused error: ModuleNotFoundError: No module named 'not_installed_something'", + ]) +# --- +# name: test_package_merge_exception[packages_include_dir_named-error0] + list([ + "Setup of package 'unknown_integration' at integrations/unknown_integration.yaml, line 1 failed: Integration test_domain caused error: No such file or directory: b'liblibc.a'", + ]) +# --- +# name: test_package_merge_exception[packages_include_dir_named-error1] + list([ + "Setup of package 'unknown_integration' at integrations/unknown_integration.yaml, line 1 failed: Integration test_domain caused error: ModuleNotFoundError: No module named 'not_installed_something'", + ]) +# --- +# name: test_yaml_error[basic] + ''' + mapping values are not allowed here + in "configuration.yaml", line 4, column 14 + ''' +# --- +# name: test_yaml_error[basic].1 + list([ + ''' + mapping values are not allowed here + in "/fixtures/core/config/yaml_errors/basic/configuration.yaml", line 4, column 14 + ''', + ]) +# --- +# name: test_yaml_error[basic_include] + ''' + mapping values are not allowed here + in "integrations/iot_domain.yaml", line 3, column 12 + ''' +# --- +# name: test_yaml_error[basic_include].1 + list([ + ''' + mapping values are not allowed here + in "/fixtures/core/config/yaml_errors/basic_include/integrations/iot_domain.yaml", line 3, column 12 + ''', + ]) +# --- +# name: test_yaml_error[include_dir_list] + ''' + mapping values are not allowed here + in "iot_domain/iot_domain_1.yaml", line 3, column 10 + ''' +# --- +# name: test_yaml_error[include_dir_list].1 + list([ + ''' + mapping values are not allowed here + in "/fixtures/core/config/yaml_errors/include_dir_list/iot_domain/iot_domain_1.yaml", line 3, column 10 + ''', + ]) +# --- +# name: test_yaml_error[include_dir_merge_list] + ''' + mapping values are not allowed here + in "iot_domain/iot_domain_1.yaml", line 3, column 12 + ''' +# --- +# name: test_yaml_error[include_dir_merge_list].1 + list([ + ''' + mapping values are not allowed here + in "/fixtures/core/config/yaml_errors/include_dir_merge_list/iot_domain/iot_domain_1.yaml", line 3, column 12 + ''', + ]) +# --- +# name: test_yaml_error[packages_include_dir_named] + ''' + mapping values are not allowed here + in "integrations/adr_0007_1.yaml", line 4, column 9 + ''' +# --- +# name: test_yaml_error[packages_include_dir_named].1 + list([ + ''' + mapping values are not allowed here + in "/fixtures/core/config/yaml_errors/packages_include_dir_named/integrations/adr_0007_1.yaml", line 4, column 9 + ''', + ]) +# --- diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 555bcbdf6b2..b98d3d0311f 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -23,8 +23,8 @@ from .common import ( MockModule, MockPlatform, get_test_config_dir, - mock_entity_platform, mock_integration, + mock_platform, ) VERSION_PATH = os.path.join(get_test_config_dir(), config_util.VERSION_FILE) @@ -327,7 +327,7 @@ async def test_setup_after_deps_via_platform(hass: HomeAssistant) -> None: partial_manifest={"after_dependencies": ["after_dep_of_platform_int"]}, ), ) - mock_entity_platform(hass, "light.platform_int", MockPlatform()) + mock_platform(hass, "platform_int.light", MockPlatform()) @callback def continue_loading(_): @@ -719,17 +719,19 @@ async def test_setup_hass_invalid_core_config( event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" - hass = await bootstrap.async_setup_hass( - runner.RuntimeConfig( - config_dir=get_test_config_dir(), - verbose=False, - log_rotate_days=10, - log_file="", - log_no_color=False, - skip_pip=True, - recovery_mode=False, - ), - ) + with patch("homeassistant.bootstrap.async_notify_setup_error") as mock_notify: + hass = await bootstrap.async_setup_hass( + runner.RuntimeConfig( + config_dir=get_test_config_dir(), + verbose=False, + log_rotate_days=10, + log_file="", + log_no_color=False, + skip_pip=True, + recovery_mode=False, + ), + ) + assert len(mock_notify.mock_calls) == 1 assert "recovery_mode" in hass.config.components @@ -1011,7 +1013,10 @@ async def test_bootstrap_dependencies( with patch( "homeassistant.setup.loader.async_get_integrations", side_effect=mock_async_get_integrations, - ), patch("homeassistant.config.async_process_component_config", return_value={}): + ), patch( + "homeassistant.config.async_process_component_config", + return_value=config_util.IntegrationConfigInfo({}, []), + ): bootstrap.async_set_domains_to_be_loaded(hass, {integration}) await bootstrap.async_setup_multi_components(hass, {integration}, {}) await hass.async_block_till_done() diff --git a/tests/test_config.py b/tests/test_config.py index d5181bbe115..de5e7e0581d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,12 +2,14 @@ from collections import OrderedDict import contextlib import copy +import logging import os from typing import Any from unittest import mock from unittest.mock import AsyncMock, Mock, patch import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol from voluptuous import Invalid, MultipleInvalid import yaml @@ -28,10 +30,12 @@ from homeassistant.const import ( __version__, ) from homeassistant.core import ConfigSource, HomeAssistant, HomeAssistantError +from homeassistant.exceptions import ConfigValidationError from homeassistant.helpers import config_validation as cv, issue_registry as ir import homeassistant.helpers.check_config as check_config from homeassistant.helpers.entity import Entity -from homeassistant.loader import async_get_integration +from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import Integration, async_get_integration from homeassistant.util.unit_system import ( _CONF_UNIT_SYSTEM_US_CUSTOMARY, METRIC_SYSTEM, @@ -40,7 +44,14 @@ from homeassistant.util.unit_system import ( ) from homeassistant.util.yaml import SECRET_YAML -from .common import MockUser, get_test_config_dir +from .common import ( + MockModule, + MockPlatform, + MockUser, + get_test_config_dir, + mock_integration, + mock_platform, +) CONFIG_DIR = get_test_config_dir() YAML_PATH = os.path.join(CONFIG_DIR, config_util.YAML_CONFIG_FILE) @@ -85,6 +96,275 @@ def teardown(): os.remove(SAFE_MODE_PATH) +IOT_DOMAIN_PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({vol.Remove("old"): str}) + + +@pytest.fixture +async def mock_iot_domain_integration(hass: HomeAssistant) -> Integration: + """Mock an integration which provides an IoT domain.""" + comp_platform_schema = cv.PLATFORM_SCHEMA.extend({vol.Remove("old"): str}) + comp_platform_schema_base = comp_platform_schema.extend({}, extra=vol.ALLOW_EXTRA) + + return mock_integration( + hass, + MockModule( + "iot_domain", + platform_schema_base=comp_platform_schema_base, + platform_schema=comp_platform_schema, + ), + ) + + +@pytest.fixture +async def mock_iot_domain_integration_with_docs(hass: HomeAssistant) -> Integration: + """Mock an integration which provides an IoT domain.""" + comp_platform_schema = cv.PLATFORM_SCHEMA.extend({vol.Remove("old"): str}) + comp_platform_schema_base = comp_platform_schema.extend({}, extra=vol.ALLOW_EXTRA) + + return mock_integration( + hass, + MockModule( + "iot_domain", + platform_schema_base=comp_platform_schema_base, + platform_schema=comp_platform_schema, + partial_manifest={ + "documentation": "https://www.home-assistant.io/integrations/iot_domain" + }, + ), + ) + + +@pytest.fixture +async def mock_non_adr_0007_integration(hass: HomeAssistant) -> None: + """Mock a non-ADR-0007 compliant integration with iot_domain platform. + + The integration allows setting up iot_domain entities under the iot_domain's + configuration key + """ + + test_platform_schema = IOT_DOMAIN_PLATFORM_SCHEMA.extend( + {vol.Required("option1"): str, vol.Optional("option2"): str} + ) + mock_platform( + hass, + "non_adr_0007.iot_domain", + MockPlatform(platform_schema=test_platform_schema), + ) + + +@pytest.fixture +async def mock_non_adr_0007_integration_with_docs(hass: HomeAssistant) -> None: + """Mock a non-ADR-0007 compliant integration with iot_domain platform. + + The integration allows setting up iot_domain entities under the iot_domain's + configuration key + """ + + mock_integration( + hass, + MockModule( + "non_adr_0007", + partial_manifest={ + "documentation": "https://www.home-assistant.io/integrations/non_adr_0007" + }, + ), + ) + test_platform_schema = IOT_DOMAIN_PLATFORM_SCHEMA.extend( + {vol.Required("option1"): str, vol.Optional("option2"): str} + ) + mock_platform( + hass, + "non_adr_0007.iot_domain", + MockPlatform(platform_schema=test_platform_schema), + ) + + +@pytest.fixture +async def mock_adr_0007_integrations(hass: HomeAssistant) -> list[Integration]: + """Mock ADR-0007 compliant integrations.""" + integrations = [] + for domain in [ + "adr_0007_1", + "adr_0007_2", + "adr_0007_3", + "adr_0007_4", + "adr_0007_5", + ]: + adr_0007_config_schema = vol.Schema( + { + domain: vol.Schema( + { + vol.Required("host"): str, + vol.Optional("port", default=8080): int, + } + ) + }, + extra=vol.ALLOW_EXTRA, + ) + integrations.append( + mock_integration( + hass, + MockModule(domain, config_schema=adr_0007_config_schema), + ) + ) + return integrations + + +@pytest.fixture +async def mock_adr_0007_integrations_with_docs( + hass: HomeAssistant, +) -> list[Integration]: + """Mock ADR-0007 compliant integrations.""" + integrations = [] + for domain in [ + "adr_0007_1", + "adr_0007_2", + "adr_0007_3", + "adr_0007_4", + "adr_0007_5", + ]: + adr_0007_config_schema = vol.Schema( + { + domain: vol.Schema( + { + vol.Required("host"): str, + vol.Optional("port", default=8080): int, + } + ) + }, + extra=vol.ALLOW_EXTRA, + ) + integrations.append( + mock_integration( + hass, + MockModule( + domain, + config_schema=adr_0007_config_schema, + partial_manifest={ + "documentation": f"https://www.home-assistant.io/integrations/{domain}" + }, + ), + ) + ) + return integrations + + +@pytest.fixture +async def mock_custom_validator_integrations(hass: HomeAssistant) -> list[Integration]: + """Mock integrations with custom validator.""" + integrations = [] + + for domain in ("custom_validator_ok_1", "custom_validator_ok_2"): + + def gen_async_validate_config(domain): + schema = vol.Schema( + { + domain: vol.Schema( + { + vol.Required("host"): str, + vol.Optional("port", default=8080): int, + } + ) + }, + extra=vol.ALLOW_EXTRA, + ) + + async def async_validate_config( + hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return schema(config) + + return async_validate_config + + integrations.append(mock_integration(hass, MockModule(domain))) + mock_platform( + hass, + f"{domain}.config", + Mock(async_validate_config=gen_async_validate_config(domain)), + ) + + for domain, exception in [ + ("custom_validator_bad_1", HomeAssistantError("broken")), + ("custom_validator_bad_2", ValueError("broken")), + ]: + integrations.append(mock_integration(hass, MockModule(domain))) + mock_platform( + hass, + f"{domain}.config", + Mock(async_validate_config=AsyncMock(side_effect=exception)), + ) + + +@pytest.fixture +async def mock_custom_validator_integrations_with_docs( + hass: HomeAssistant, +) -> list[Integration]: + """Mock integrations with custom validator.""" + integrations = [] + + for domain in ("custom_validator_ok_1", "custom_validator_ok_2"): + + def gen_async_validate_config(domain): + schema = vol.Schema( + { + domain: vol.Schema( + { + vol.Required("host"): str, + vol.Optional("port", default=8080): int, + } + ) + }, + extra=vol.ALLOW_EXTRA, + ) + + async def async_validate_config( + hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return schema(config) + + return async_validate_config + + integrations.append( + mock_integration( + hass, + MockModule( + domain, + partial_manifest={ + "documentation": f"https://www.home-assistant.io/integrations/{domain}" + }, + ), + ) + ) + mock_platform( + hass, + f"{domain}.config", + Mock(async_validate_config=gen_async_validate_config(domain)), + ) + + for domain, exception in [ + ("custom_validator_bad_1", HomeAssistantError("broken")), + ("custom_validator_bad_2", ValueError("broken")), + ]: + integrations.append( + mock_integration( + hass, + MockModule( + domain, + partial_manifest={ + "documentation": f"https://www.home-assistant.io/integrations/{domain}" + }, + ), + ) + ) + mock_platform( + hass, + f"{domain}.config", + Mock(async_validate_config=AsyncMock(side_effect=exception)), + ) + + async def test_create_default_config(hass: HomeAssistant) -> None: """Test creation of default config.""" assert not os.path.isfile(YAML_PATH) @@ -1148,71 +1428,132 @@ async def test_component_config_exceptions( ) -> None: """Test unexpected exceptions validating component config.""" # Config validator + test_integration = Mock( + domain="test_domain", + get_platform=Mock( + return_value=Mock( + async_validate_config=AsyncMock(side_effect=ValueError("broken")) + ) + ), + ) assert ( - await config_util.async_process_component_config( - hass, - {}, - integration=Mock( - domain="test_domain", - get_platform=Mock( - return_value=Mock( - async_validate_config=AsyncMock( - side_effect=ValueError("broken") - ) - ) - ), - ), + await config_util.async_process_component_and_handle_errors( + hass, {}, integration=test_integration ) is None ) assert "ValueError: broken" in caplog.text assert "Unknown error calling test_domain config validator" in caplog.text + caplog.clear() + with pytest.raises(HomeAssistantError) as ex: + await config_util.async_process_component_and_handle_errors( + hass, {}, integration=test_integration, raise_on_failure=True + ) + assert "ValueError: broken" in caplog.text + assert "Unknown error calling test_domain config validator" in caplog.text + assert str(ex.value) == "Unknown error calling test_domain config validator" - # component.CONFIG_SCHEMA + test_integration = Mock( + domain="test_domain", + get_platform=Mock( + return_value=Mock( + async_validate_config=AsyncMock( + side_effect=HomeAssistantError("broken") + ) + ) + ), + get_component=Mock(return_value=Mock(spec=["PLATFORM_SCHEMA_BASE"])), + ) caplog.clear() assert ( - await config_util.async_process_component_config( - hass, - {}, - integration=Mock( - domain="test_domain", - get_platform=Mock(return_value=None), - get_component=Mock( - return_value=Mock( - CONFIG_SCHEMA=Mock(side_effect=ValueError("broken")) - ) - ), - ), + await config_util.async_process_component_and_handle_errors( + hass, {}, integration=test_integration, raise_on_failure=False + ) + is None + ) + assert "Invalid config for 'test_domain': broken" in caplog.text + with pytest.raises(HomeAssistantError) as ex: + await config_util.async_process_component_and_handle_errors( + hass, {}, integration=test_integration, raise_on_failure=True + ) + assert "Invalid config for 'test_domain': broken" in str(ex.value) + + # component.CONFIG_SCHEMA + caplog.clear() + test_integration = Mock( + domain="test_domain", + get_platform=Mock(return_value=None), + get_component=Mock( + return_value=Mock(CONFIG_SCHEMA=Mock(side_effect=ValueError("broken"))) + ), + ) + assert ( + await config_util.async_process_component_and_handle_errors( + hass, + {}, + integration=test_integration, + raise_on_failure=False, ) is None ) - assert "ValueError: broken" in caplog.text assert "Unknown error calling test_domain CONFIG_SCHEMA" in caplog.text + with pytest.raises(HomeAssistantError) as ex: + await config_util.async_process_component_and_handle_errors( + hass, + {}, + integration=test_integration, + raise_on_failure=True, + ) + assert "Unknown error calling test_domain CONFIG_SCHEMA" in caplog.text + assert str(ex.value) == "Unknown error calling test_domain CONFIG_SCHEMA" # component.PLATFORM_SCHEMA caplog.clear() - assert await config_util.async_process_component_config( + test_integration = Mock( + domain="test_domain", + get_platform=Mock(return_value=None), + get_component=Mock( + return_value=Mock( + spec=["PLATFORM_SCHEMA_BASE"], + PLATFORM_SCHEMA_BASE=Mock(side_effect=ValueError("broken")), + ) + ), + ) + assert await config_util.async_process_component_and_handle_errors( hass, {"test_domain": {"platform": "test_platform"}}, - integration=Mock( - domain="test_domain", - get_platform=Mock(return_value=None), - get_component=Mock( - return_value=Mock( - spec=["PLATFORM_SCHEMA_BASE"], - PLATFORM_SCHEMA_BASE=Mock(side_effect=ValueError("broken")), - ) - ), - ), + integration=test_integration, + raise_on_failure=False, ) == {"test_domain": []} assert "ValueError: broken" in caplog.text assert ( - "Unknown error validating test_platform platform config " - "with test_domain component platform schema" + "Unknown error validating config for test_platform platform " + "for test_domain component with PLATFORM_SCHEMA" ) in caplog.text + caplog.clear() + with pytest.raises(HomeAssistantError) as ex: + await config_util.async_process_component_and_handle_errors( + hass, + {"test_domain": {"platform": "test_platform"}}, + integration=test_integration, + raise_on_failure=True, + ) + assert ( + "Unknown error validating config for test_platform platform " + "for test_domain component with PLATFORM_SCHEMA" + ) in caplog.text + assert str(ex.value) == ( + "Unknown error validating config for test_platform platform " + "for test_domain component with PLATFORM_SCHEMA" + ) # platform.PLATFORM_SCHEMA caplog.clear() + test_integration = Mock( + domain="test_domain", + get_platform=Mock(return_value=None), + get_component=Mock(return_value=Mock(spec=["PLATFORM_SCHEMA_BASE"])), + ) with patch( "homeassistant.config.async_get_integration_with_requirements", return_value=Mock( # integration that owns platform @@ -1223,67 +1564,337 @@ async def test_component_config_exceptions( ) ), ): - assert await config_util.async_process_component_config( + assert await config_util.async_process_component_and_handle_errors( hass, {"test_domain": {"platform": "test_platform"}}, - integration=Mock( - domain="test_domain", - get_platform=Mock(return_value=None), - get_component=Mock(return_value=Mock(spec=["PLATFORM_SCHEMA_BASE"])), - ), + integration=test_integration, + raise_on_failure=False, ) == {"test_domain": []} assert "ValueError: broken" in caplog.text + assert ( + "Unknown error validating config for test_platform platform for test_domain" + " component with PLATFORM_SCHEMA" + ) in caplog.text + caplog.clear() + with pytest.raises(HomeAssistantError) as ex: + assert await config_util.async_process_component_and_handle_errors( + hass, + {"test_domain": {"platform": "test_platform"}}, + integration=test_integration, + raise_on_failure=True, + ) + assert ( + "Unknown error validating config for test_platform platform for test_domain" + " component with PLATFORM_SCHEMA" + ) in str(ex.value) + assert "ValueError: broken" in caplog.text assert ( "Unknown error validating config for test_platform platform for test_domain" " component with PLATFORM_SCHEMA" in caplog.text ) + # Test multiple platform failures + assert await config_util.async_process_component_and_handle_errors( + hass, + { + "test_domain": [ + {"platform": "test_platform1"}, + {"platform": "test_platform2"}, + ] + }, + integration=test_integration, + raise_on_failure=False, + ) == {"test_domain": []} + assert "ValueError: broken" in caplog.text + assert ( + "Unknown error validating config for test_platform1 platform " + "for test_domain component with PLATFORM_SCHEMA" + ) in caplog.text + assert ( + "Unknown error validating config for test_platform2 platform " + "for test_domain component with PLATFORM_SCHEMA" + ) in caplog.text + caplog.clear() + with pytest.raises(HomeAssistantError) as ex: + assert await config_util.async_process_component_and_handle_errors( + hass, + { + "test_domain": [ + {"platform": "test_platform1"}, + {"platform": "test_platform2"}, + ] + }, + integration=test_integration, + raise_on_failure=True, + ) + assert ( + "Failed to process component config for integration test_domain" + " due to multiple errors (2), check the logs for more information." + ) in str(ex.value) + assert "ValueError: broken" in caplog.text + assert ( + "Unknown error validating config for test_platform1 platform " + "for test_domain component with PLATFORM_SCHEMA" + ) in caplog.text + assert ( + "Unknown error validating config for test_platform2 platform " + "for test_domain component with PLATFORM_SCHEMA" + ) in caplog.text + + # get_platform("domain") raising on ImportError + caplog.clear() + test_integration = Mock( + domain="test_domain", + get_platform=Mock(return_value=None), + get_component=Mock(return_value=Mock(spec=["PLATFORM_SCHEMA_BASE"])), + ) + import_error = ImportError( + ("ModuleNotFoundError: No module named 'not_installed_something'"), + name="not_installed_something", + ) + with patch( + "homeassistant.config.async_get_integration_with_requirements", + return_value=Mock( # integration that owns platform + get_platform=Mock(side_effect=import_error) + ), + ): + assert await config_util.async_process_component_and_handle_errors( + hass, + {"test_domain": {"platform": "test_platform"}}, + integration=test_integration, + raise_on_failure=False, + ) == {"test_domain": []} + assert ( + "ImportError: ModuleNotFoundError: No module named " + "'not_installed_something'" in caplog.text + ) + caplog.clear() + with pytest.raises(HomeAssistantError) as ex: + assert await config_util.async_process_component_and_handle_errors( + hass, + {"test_domain": {"platform": "test_platform"}}, + integration=test_integration, + raise_on_failure=True, + ) + assert ( + "ImportError: ModuleNotFoundError: No module named " + "'not_installed_something'" in caplog.text + ) + assert ( + "Platform error: test_domain - ModuleNotFoundError: " + "No module named 'not_installed_something'" + ) in caplog.text + assert ( + "Platform error: test_domain - ModuleNotFoundError: " + "No module named 'not_installed_something'" + ) in str(ex.value) # get_platform("config") raising caplog.clear() + test_integration = Mock( + pkg_path="homeassistant.components.test_domain", + domain="test_domain", + get_platform=Mock( + side_effect=ImportError( + ("ModuleNotFoundError: No module named 'not_installed_something'"), + name="not_installed_something", + ) + ), + ) assert ( - await config_util.async_process_component_config( + await config_util.async_process_component_and_handle_errors( hass, {"test_domain": {}}, - integration=Mock( - pkg_path="homeassistant.components.test_domain", - domain="test_domain", - get_platform=Mock( - side_effect=ImportError( - ( - "ModuleNotFoundError: No module named" - " 'not_installed_something'" - ), - name="not_installed_something", - ) - ), - ), + integration=test_integration, + raise_on_failure=False, ) is None ) assert ( - "Error importing config platform test_domain: ModuleNotFoundError: No module" - " named 'not_installed_something'" in caplog.text + "Error importing config platform test_domain: ModuleNotFoundError: " + "No module named 'not_installed_something'" in caplog.text + ) + with pytest.raises(HomeAssistantError) as ex: + await config_util.async_process_component_and_handle_errors( + hass, + {"test_domain": {}}, + integration=test_integration, + raise_on_failure=True, + ) + assert ( + "Error importing config platform test_domain: ModuleNotFoundError: " + "No module named 'not_installed_something'" in caplog.text + ) + assert ( + "Error importing config platform test_domain: ModuleNotFoundError: " + "No module named 'not_installed_something'" in str(ex.value) ) # get_component raising caplog.clear() + test_integration = Mock( + pkg_path="homeassistant.components.test_domain", + domain="test_domain", + get_component=Mock( + side_effect=FileNotFoundError("No such file or directory: b'liblibc.a'") + ), + ) assert ( - await config_util.async_process_component_config( + await config_util.async_process_component_and_handle_errors( hass, {"test_domain": {}}, - integration=Mock( - pkg_path="homeassistant.components.test_domain", - domain="test_domain", - get_component=Mock( - side_effect=FileNotFoundError( - "No such file or directory: b'liblibc.a'" - ) - ), - ), + integration=test_integration, + raise_on_failure=False, ) is None ) assert "Unable to import test_domain: No such file or directory" in caplog.text + with pytest.raises(HomeAssistantError) as ex: + await config_util.async_process_component_and_handle_errors( + hass, + {"test_domain": {}}, + integration=test_integration, + raise_on_failure=True, + ) + assert "Unable to import test_domain: No such file or directory" in caplog.text + assert "Unable to import test_domain: No such file or directory" in str(ex.value) + + +@pytest.mark.parametrize( + ("exception_info_list", "error", "messages", "show_stack_trace", "translation_key"), + [ + ( + [ + config_util.ConfigExceptionInfo( + ImportError("bla"), + "component_import_err", + "test_domain", + {"test_domain": []}, + "https://example.com", + ) + ], + "bla", + ["Unable to import test_domain: bla", "bla"], + False, + "component_import_err", + ), + ( + [ + config_util.ConfigExceptionInfo( + HomeAssistantError("bla"), + "config_validation_err", + "test_domain", + {"test_domain": []}, + "https://example.com", + ) + ], + "bla", + [ + "Invalid config for 'test_domain': bla, " + "please check the docs at https://example.com", + "bla", + ], + True, + "config_validation_err", + ), + ( + [ + config_util.ConfigExceptionInfo( + vol.Invalid("bla", ["path"]), + "config_validation_err", + "test_domain", + {"test_domain": []}, + "https://example.com", + ) + ], + "bla @ data['path']", + [ + "Invalid config for 'test_domain': bla 'path', got None, " + "please check the docs at https://example.com", + "bla", + ], + False, + "config_validation_err", + ), + ( + [ + config_util.ConfigExceptionInfo( + vol.Invalid("bla", ["path"]), + "platform_config_validation_err", + "test_domain", + {"test_domain": []}, + "https://alt.example.com", + ) + ], + "bla @ data['path']", + [ + "Invalid config for 'test_domain': bla 'path', got None, " + "please check the docs at https://alt.example.com", + "bla", + ], + False, + "platform_config_validation_err", + ), + ( + [ + config_util.ConfigExceptionInfo( + ImportError("bla"), + "platform_component_load_err", + "test_domain", + {"test_domain": []}, + "https://example.com", + ) + ], + "bla", + ["Platform error: test_domain - bla", "bla"], + False, + "platform_component_load_err", + ), + ], +) +async def test_component_config_error_processing( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + error: str, + exception_info_list: list[config_util.ConfigExceptionInfo], + messages: list[str], + show_stack_trace: bool, + translation_key: str, +) -> None: + """Test component config error processing.""" + test_integration = Mock( + domain="test_domain", + documentation="https://example.com", + get_platform=Mock( + return_value=Mock( + async_validate_config=AsyncMock(side_effect=ValueError("broken")) + ) + ), + ) + with patch( + "homeassistant.config.async_process_component_config", + return_value=config_util.IntegrationConfigInfo(None, exception_info_list), + ), pytest.raises(ConfigValidationError) as ex: + await config_util.async_process_component_and_handle_errors( + hass, {}, test_integration, raise_on_failure=True + ) + records = [record for record in caplog.records if record.msg == messages[0]] + assert len(records) == 1 + assert (records[0].exc_info is not None) == show_stack_trace + assert str(ex.value) == messages[0] + assert ex.value.translation_key == translation_key + assert ex.value.translation_domain == "homeassistant" + assert ex.value.translation_placeholders["domain"] == "test_domain" + assert all(message in caplog.text for message in messages) + + caplog.clear() + with patch( + "homeassistant.config.async_process_component_config", + return_value=config_util.IntegrationConfigInfo(None, exception_info_list), + ): + await config_util.async_process_component_and_handle_errors( + hass, {}, test_integration + ) + assert all(message in caplog.text for message in messages) @pytest.mark.parametrize( @@ -1399,3 +2010,200 @@ async def test_safe_mode(hass: HomeAssistant) -> None: await config_util.async_enable_safe_mode(hass) assert config_util.safe_mode_enabled(hass.config.config_dir) is True assert config_util.safe_mode_enabled(hass.config.config_dir) is False + + +@pytest.mark.parametrize( + "config_dir", + [ + "basic", + "basic_include", + "include_dir_list", + "include_dir_merge_list", + "packages", + "packages_include_dir_named", + ], +) +async def test_component_config_validation_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + config_dir: str, + mock_iot_domain_integration: Integration, + mock_non_adr_0007_integration: None, + mock_adr_0007_integrations: list[Integration], + mock_custom_validator_integrations: list[Integration], + snapshot: SnapshotAssertion, +) -> None: + """Test schema error in component.""" + + base_path = os.path.dirname(__file__) + hass.config.config_dir = os.path.join( + base_path, "fixtures", "core", "config", "component_validation", config_dir + ) + config = await config_util.async_hass_config_yaml(hass) + + for domain_with_label in config: + integration = await async_get_integration( + hass, domain_with_label.partition(" ")[0] + ) + await config_util.async_process_component_and_handle_errors( + hass, + config, + integration=integration, + ) + + error_records = [ + { + "message": record.message, + "has_exc_info": bool(record.exc_info), + } + for record in caplog.get_records("call") + if record.levelno == logging.ERROR + ] + assert error_records == snapshot + + +@pytest.mark.parametrize( + "config_dir", + [ + "basic", + ], +) +async def test_component_config_validation_error_with_docs( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + config_dir: str, + mock_iot_domain_integration_with_docs: Integration, + mock_non_adr_0007_integration_with_docs: None, + mock_adr_0007_integrations_with_docs: list[Integration], + mock_custom_validator_integrations_with_docs: list[Integration], + snapshot: SnapshotAssertion, +) -> None: + """Test schema error in component.""" + + base_path = os.path.dirname(__file__) + hass.config.config_dir = os.path.join( + base_path, "fixtures", "core", "config", "component_validation", config_dir + ) + config = await config_util.async_hass_config_yaml(hass) + + for domain_with_label in config: + integration = await async_get_integration( + hass, domain_with_label.partition(" ")[0] + ) + await config_util.async_process_component_and_handle_errors( + hass, + config, + integration=integration, + ) + + error_records = [ + record.message + for record in caplog.get_records("call") + if record.levelno == logging.ERROR + ] + assert error_records == snapshot + + +@pytest.mark.parametrize( + "config_dir", + ["packages", "packages_include_dir_named"], +) +async def test_package_merge_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + config_dir: str, + mock_iot_domain_integration: Integration, + mock_non_adr_0007_integration: None, + mock_adr_0007_integrations: list[Integration], + snapshot: SnapshotAssertion, +) -> None: + """Test schema error in component.""" + base_path = os.path.dirname(__file__) + hass.config.config_dir = os.path.join( + base_path, "fixtures", "core", "config", "package_errors", config_dir + ) + await config_util.async_hass_config_yaml(hass) + + error_records = [ + record.message + for record in caplog.get_records("call") + if record.levelno == logging.ERROR + ] + assert error_records == snapshot + + +@pytest.mark.parametrize( + "error", + [ + FileNotFoundError("No such file or directory: b'liblibc.a'"), + ImportError( + ("ModuleNotFoundError: No module named 'not_installed_something'"), + name="not_installed_something", + ), + ], +) +@pytest.mark.parametrize( + "config_dir", + ["packages", "packages_include_dir_named"], +) +async def test_package_merge_exception( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + config_dir: str, + error: Exception, + snapshot: SnapshotAssertion, +) -> None: + """Test exception when merging packages.""" + base_path = os.path.dirname(__file__) + hass.config.config_dir = os.path.join( + base_path, "fixtures", "core", "config", "package_exceptions", config_dir + ) + with patch( + "homeassistant.config.async_get_integration_with_requirements", + side_effect=error, + ): + await config_util.async_hass_config_yaml(hass) + + error_records = [ + record.message + for record in caplog.get_records("call") + if record.levelno == logging.ERROR + ] + assert error_records == snapshot + + +@pytest.mark.parametrize( + "config_dir", + [ + "basic", + "basic_include", + "include_dir_list", + "include_dir_merge_list", + "packages_include_dir_named", + ], +) +async def test_yaml_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + config_dir: str, + mock_iot_domain_integration: Integration, + mock_non_adr_0007_integration: None, + mock_adr_0007_integrations: list[Integration], + snapshot: SnapshotAssertion, +) -> None: + """Test schema error in component.""" + + base_path = os.path.dirname(__file__) + hass.config.config_dir = os.path.join( + base_path, "fixtures", "core", "config", "yaml_errors", config_dir + ) + with pytest.raises(HomeAssistantError) as exc_info: + await config_util.async_hass_config_yaml(hass) + assert str(exc_info.value).replace(base_path, "") == snapshot + + error_records = [ + record.message.replace(base_path, "") + for record in caplog.get_records("call") + if record.levelno == logging.ERROR + ] + assert error_records == snapshot diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index eb771b7e6a6..f63972c79e8 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -40,8 +40,8 @@ from .common import ( MockPlatform, async_fire_time_changed, mock_config_flow, - mock_entity_platform, mock_integration, + mock_platform, ) from tests.common import async_get_persistent_notifications @@ -92,7 +92,7 @@ async def test_call_setup_entry(hass: HomeAssistant) -> None: async_migrate_entry=mock_migrate_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) with patch("homeassistant.config_entries.support_entry_unload", return_value=True): result = await async_setup_component(hass, "comp", {}) @@ -121,7 +121,7 @@ async def test_call_setup_entry_without_reload_support(hass: HomeAssistant) -> N async_migrate_entry=mock_migrate_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) with patch("homeassistant.config_entries.support_entry_unload", return_value=False): result = await async_setup_component(hass, "comp", {}) @@ -151,7 +151,7 @@ async def test_call_async_migrate_entry(hass: HomeAssistant) -> None: async_migrate_entry=mock_migrate_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) with patch("homeassistant.config_entries.support_entry_unload", return_value=True): result = await async_setup_component(hass, "comp", {}) @@ -181,7 +181,7 @@ async def test_call_async_migrate_entry_failure_false(hass: HomeAssistant) -> No async_migrate_entry=mock_migrate_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) result = await async_setup_component(hass, "comp", {}) assert result @@ -209,7 +209,7 @@ async def test_call_async_migrate_entry_failure_exception(hass: HomeAssistant) - async_migrate_entry=mock_migrate_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) result = await async_setup_component(hass, "comp", {}) assert result @@ -237,7 +237,7 @@ async def test_call_async_migrate_entry_failure_not_bool(hass: HomeAssistant) -> async_migrate_entry=mock_migrate_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) result = await async_setup_component(hass, "comp", {}) assert result @@ -259,7 +259,7 @@ async def test_call_async_migrate_entry_failure_not_supported( mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) result = await async_setup_component(hass, "comp", {}) assert result @@ -269,7 +269,9 @@ async def test_call_async_migrate_entry_failure_not_supported( async def test_remove_entry( - hass: HomeAssistant, manager: config_entries.ConfigEntries + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + entity_registry: er.EntityRegistry, ) -> None: """Test that we can remove an entry.""" @@ -309,10 +311,10 @@ async def test_remove_entry( async_remove_entry=mock_remove_entry, ), ) - mock_entity_platform( - hass, "light.test", MockPlatform(async_setup_entry=mock_setup_entry_platform) + mock_platform( + hass, "test.light", MockPlatform(async_setup_entry=mock_setup_entry_platform) ) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) MockConfigEntry(domain="test_other", entry_id="test1").add_to_manager(manager) entry = MockConfigEntry(domain="test", entry_id="test2") @@ -335,9 +337,8 @@ async def test_remove_entry( assert len(hass.states.async_all()) == 1 # Check entity got added to entity registry - ent_reg = er.async_get(hass) - assert len(ent_reg.entities) == 1 - entity_entry = list(ent_reg.entities.values())[0] + assert len(entity_registry.entities) == 1 + entity_entry = list(entity_registry.entities.values())[0] assert entity_entry.config_entry_id == entry.entry_id # Remove entry @@ -358,7 +359,7 @@ async def test_remove_entry( assert len(hass.states.async_all()) == 0 # Check that entity registry entry has been removed - entity_entry_list = list(ent_reg.entities.values()) + entity_entry_list = list(entity_registry.entities.values()) assert not entity_entry_list @@ -370,7 +371,7 @@ async def test_remove_entry_cancels_reauth( mock_setup_entry = AsyncMock(side_effect=ConfigEntryAuthFailed()) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) entry.add_to_hass(hass) await entry.async_setup(hass) @@ -509,7 +510,7 @@ async def test_add_entry_calls_setup_entry( mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -609,7 +610,7 @@ async def test_saving_and_loading(hass: HomeAssistant) -> None: "test", async_setup_entry=lambda *args: AsyncMock(return_value=True) ), ) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -720,7 +721,7 @@ async def test_discovery_notification( ) -> None: """Test that we create/dismiss a notification when source is discovery.""" mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) with patch.dict(config_entries.HANDLERS): @@ -774,7 +775,7 @@ async def test_discovery_notification( async def test_reauth_notification(hass: HomeAssistant) -> None: """Test that we create/dismiss a notification when source is reauth.""" mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) with patch.dict(config_entries.HANDLERS): @@ -841,7 +842,7 @@ async def test_reauth_notification(hass: HomeAssistant) -> None: async def test_discovery_notification_not_created(hass: HomeAssistant) -> None: """Test that we not create a notification when discovery is aborted.""" mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -939,7 +940,7 @@ async def test_setup_raise_not_ready( side_effect=ConfigEntryNotReady("The internet connection is offline") ) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) with patch("homeassistant.config_entries.async_call_later") as mock_call: await entry.async_setup(hass) @@ -977,7 +978,7 @@ async def test_setup_raise_not_ready_from_exception( mock_setup_entry = AsyncMock(side_effect=config_entry_exception) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) with patch("homeassistant.config_entries.async_call_later") as mock_call: await entry.async_setup(hass) @@ -995,7 +996,7 @@ async def test_setup_retrying_during_unload(hass: HomeAssistant) -> None: mock_setup_entry = AsyncMock(side_effect=ConfigEntryNotReady) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) with patch("homeassistant.config_entries.async_call_later") as mock_call: await entry.async_setup(hass) @@ -1017,7 +1018,7 @@ async def test_setup_retrying_during_unload_before_started(hass: HomeAssistant) mock_setup_entry = AsyncMock(side_effect=ConfigEntryNotReady) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) await entry.async_setup(hass) await hass.async_block_till_done() @@ -1042,7 +1043,7 @@ async def test_setup_does_not_retry_during_shutdown(hass: HomeAssistant) -> None mock_setup_entry = AsyncMock(side_effect=ConfigEntryNotReady) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) await entry.async_setup(hass) @@ -1080,7 +1081,7 @@ async def test_create_entry_options( "comp", async_setup=mock_async_setup, async_setup_entry=async_setup_entry ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -1113,7 +1114,7 @@ async def test_entry_options( ) -> None: """Test that we can set options on an entry.""" mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_manager(manager) @@ -1151,7 +1152,7 @@ async def test_entry_options_abort( ) -> None: """Test that we can abort options flow.""" mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_manager(manager) @@ -1185,7 +1186,7 @@ async def test_entry_options_unknown_config_entry( ) -> None: """Test that we can abort options flow.""" mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) class TestFlow: """Test flow.""" @@ -1217,7 +1218,7 @@ async def test_entry_setup_succeed( hass, MockModule("comp", async_setup=mock_setup, async_setup_entry=mock_setup_entry), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) assert await manager.async_setup(entry.entry_id) assert len(mock_setup.mock_calls) == 1 @@ -1349,7 +1350,7 @@ async def test_entry_reload_succeed( async_unload_entry=async_unload_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) assert await manager.async_reload(entry.entry_id) assert len(async_unload_entry.mock_calls) == 1 @@ -1388,7 +1389,7 @@ async def test_entry_reload_not_loaded( async_unload_entry=async_unload_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) assert await manager.async_reload(entry.entry_id) assert len(async_unload_entry.mock_calls) == 0 @@ -1457,7 +1458,7 @@ async def test_entry_disable_succeed( async_unload_entry=async_unload_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) # Disable assert await manager.async_set_disabled_by( @@ -1494,7 +1495,7 @@ async def test_entry_disable_without_reload_support( async_setup_entry=async_setup_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) # Disable assert not await manager.async_set_disabled_by( @@ -1535,7 +1536,7 @@ async def test_entry_enable_without_reload_support( async_setup_entry=async_setup_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) # Enable assert await manager.async_set_disabled_by(entry.entry_id, None) @@ -1581,7 +1582,7 @@ async def test_init_custom_integration_with_missing_handler( hass, MockModule("hue"), ) - mock_entity_platform(hass, "config_flow.hue", None) + mock_platform(hass, "hue.config_flow", None) with pytest.raises(data_entry_flow.UnknownHandler), patch( "homeassistant.loader.async_get_integration", return_value=integration, @@ -1633,7 +1634,7 @@ async def test_reload_entry_entity_registry_works( async_unload_entry=mock_unload_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) # Only changing disabled_by should update trigger entity_entry = entity_registry.async_get_or_create( @@ -1675,7 +1676,7 @@ async def test_unique_id_persisted( mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -1723,7 +1724,7 @@ async def test_unique_id_existing_entry( async_remove_entry=async_remove_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -1771,7 +1772,7 @@ async def test_entry_id_existing_entry( hass, MockModule("comp"), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -1810,7 +1811,7 @@ async def test_unique_id_update_existing_entry_without_reload( hass, MockModule("comp"), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -1856,7 +1857,7 @@ async def test_unique_id_update_existing_entry_with_reload( hass, MockModule("comp"), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) updates = {"host": "1.1.1.1"} class TestFlow(config_entries.ConfigFlow): @@ -1922,7 +1923,7 @@ async def test_unique_id_from_discovery_in_setup_retry( hass, MockModule("comp"), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -1990,7 +1991,7 @@ async def test_unique_id_not_update_existing_entry( hass, MockModule("comp"), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2024,7 +2025,7 @@ async def test_unique_id_in_progress( ) -> None: """Test that we abort if there is already a flow in progress with same unique id.""" mock_integration(hass, MockModule("comp")) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2060,7 +2061,7 @@ async def test_finish_flow_aborts_progress( hass, MockModule("comp", async_setup_entry=AsyncMock(return_value=True)), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2099,7 +2100,7 @@ async def test_unique_id_ignore( """Test that we can ignore flows that are in progress and have a unique ID.""" async_setup_entry = AsyncMock(return_value=False) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2156,7 +2157,7 @@ async def test_manual_add_overrides_ignored_entry( hass, MockModule("comp"), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2203,7 +2204,7 @@ async def test_manual_add_overrides_ignored_entry_singleton( mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2244,7 +2245,7 @@ async def test__async_current_entries_does_not_skip_ignore_non_user( mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2281,7 +2282,7 @@ async def test__async_current_entries_explicit_skip_ignore( mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2322,7 +2323,7 @@ async def test__async_current_entries_explicit_include_ignore( mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2350,7 +2351,7 @@ async def test_unignore_step_form( """Test that we can ignore flows that are in progress and have a unique ID, then rediscover them.""" async_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2397,7 +2398,7 @@ async def test_unignore_create_entry( """Test that we can ignore flows that are in progress and have a unique ID, then rediscover them.""" async_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2447,7 +2448,7 @@ async def test_unignore_default_impl( """Test that resdicovery is a no-op by default.""" async_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2481,7 +2482,7 @@ async def test_partial_flows_hidden( """Test that flows that don't have a cur_step and haven't finished initing are hidden.""" async_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) # A flag to test our assertion that `async_step_discovery` was called and is in its blocked state # This simulates if the step was e.g. doing network i/o @@ -2561,7 +2562,7 @@ async def test_async_setup_init_entry( "comp", async_setup=mock_async_setup, async_setup_entry=async_setup_entry ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2615,7 +2616,7 @@ async def test_async_setup_init_entry_completes_before_loaded_event_fires( "comp", async_setup=mock_async_setup, async_setup_entry=async_setup_entry ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2681,7 +2682,7 @@ async def test_async_setup_update_entry(hass: HomeAssistant) -> None: async_setup_entry=mock_async_setup_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2733,7 +2734,7 @@ async def test_flow_with_default_discovery( hass, MockModule("comp", async_setup_entry=AsyncMock(return_value=True)), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2780,7 +2781,7 @@ async def test_flow_with_default_discovery_with_unique_id( ) -> None: """Test discovery flow using the default discovery is ignored when unique ID is set.""" mock_integration(hass, MockModule("comp")) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2817,7 +2818,7 @@ async def test_default_discovery_abort_existing_entries( entry.add_to_hass(hass) mock_integration(hass, MockModule("comp")) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2837,7 +2838,7 @@ async def test_default_discovery_in_progress( ) -> None: """Test that a flow using default discovery can only be triggered once.""" mock_integration(hass, MockModule("comp")) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2877,7 +2878,7 @@ async def test_default_discovery_abort_on_new_unique_flow( ) -> None: """Test that a flow using default discovery is aborted when a second flow with unique ID is created.""" mock_integration(hass, MockModule("comp")) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2919,7 +2920,7 @@ async def test_default_discovery_abort_on_user_flow_complete( ) -> None: """Test that a flow using default discovery is aborted when a second flow completes.""" mock_integration(hass, MockModule("comp")) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -2976,7 +2977,7 @@ async def test_flow_same_device_multiple_sources( hass, MockModule("comp", async_setup_entry=AsyncMock(return_value=True)), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -3087,7 +3088,7 @@ async def test_entry_reload_calls_on_unload_listeners( async_unload_entry=async_unload_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) mock_unload_callback = Mock() @@ -3118,7 +3119,7 @@ async def test_setup_raise_entry_error( side_effect=ConfigEntryError("Incompatible firmware version") ) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) await entry.async_setup(hass) await hass.async_block_till_done() @@ -3155,7 +3156,7 @@ async def test_setup_raise_entry_error_from_first_coordinator_update( return True mock_integration(hass, MockModule("test", async_setup_entry=async_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) await entry.async_setup(hass) await hass.async_block_till_done() @@ -3192,7 +3193,7 @@ async def test_setup_not_raise_entry_error_from_future_coordinator_update( return True mock_integration(hass, MockModule("test", async_setup_entry=async_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) await entry.async_setup(hass) await hass.async_block_till_done() @@ -3214,7 +3215,7 @@ async def test_setup_raise_auth_failed( side_effect=ConfigEntryAuthFailed("The password is no longer valid") ) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) await entry.async_setup(hass) await hass.async_block_till_done() @@ -3266,7 +3267,7 @@ async def test_setup_raise_auth_failed_from_first_coordinator_update( return True mock_integration(hass, MockModule("test", async_setup_entry=async_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) await entry.async_setup(hass) await hass.async_block_till_done() @@ -3315,7 +3316,7 @@ async def test_setup_raise_auth_failed_from_future_coordinator_update( return True mock_integration(hass, MockModule("test", async_setup_entry=async_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) await entry.async_setup(hass) await hass.async_block_till_done() @@ -3360,7 +3361,7 @@ async def test_setup_retrying_during_shutdown(hass: HomeAssistant) -> None: mock_setup_entry = AsyncMock(side_effect=ConfigEntryNotReady) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) with patch("homeassistant.helpers.event.async_call_later") as mock_call: await entry.async_setup(hass) @@ -3443,7 +3444,7 @@ async def test__async_abort_entries_match( mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -3529,7 +3530,7 @@ async def test__async_abort_entries_match_options_flow( mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("test_abort", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test_abort", None) + mock_platform(hass, "test_abort.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -3648,7 +3649,7 @@ async def test_entry_reload_concurrency( async_unload_entry=_async_unload_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) tasks = [] for _ in range(15): tasks.append(asyncio.create_task(manager.async_reload(entry.entry_id))) @@ -3688,7 +3689,7 @@ async def test_unique_id_update_while_setup_in_progress( async_unload_entry=mock_unload_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) updates = {"host": "1.1.1.1"} hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) @@ -3751,7 +3752,7 @@ async def test_reauth(hass: HomeAssistant) -> None: mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) await entry.async_setup(hass) await hass.async_block_till_done() @@ -3811,7 +3812,7 @@ async def test_get_active_flows(hass: HomeAssistant) -> None: entry = MockConfigEntry(title="test_title", domain="test") mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) await entry.async_setup(hass) await hass.async_block_till_done() @@ -3844,7 +3845,7 @@ async def test_async_wait_component_dynamic(hass: HomeAssistant) -> None: mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) entry.add_to_hass(hass) @@ -3875,7 +3876,7 @@ async def test_async_wait_component_startup(hass: HomeAssistant) -> None: hass, MockModule("test", async_setup=mock_setup, async_setup_entry=mock_setup_entry), ) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) entry.add_to_hass(hass) @@ -3937,7 +3938,7 @@ async def test_initializing_flows_canceled_on_shutdown( await asyncio.sleep(1) mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) with patch.dict( config_entries.HANDLERS, {"comp": MockFlowHandler, "test": MockFlowHandler} @@ -4009,7 +4010,7 @@ async def test_preview_supported( preview_calls.append(None) mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) assert len(preview_calls) == 0 @@ -4045,7 +4046,7 @@ async def test_preview_not_supported( raise NotImplementedError mock_integration(hass, MockModule("test")) - mock_entity_platform(hass, "config_flow.test", None) + mock_platform(hass, "test.config_flow", None) with patch.dict( config_entries.HANDLERS, {"comp": MockFlowHandler, "test": MockFlowHandler} diff --git a/tests/test_core.py b/tests/test_core.py index 957da634dce..43291c032d7 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -816,6 +816,16 @@ async def test_eventbus_run_immediately(hass: HomeAssistant) -> None: unsub() +async def test_eventbus_run_immediately_not_callback(hass: HomeAssistant) -> None: + """Test we raise when passing a non-callback with run_immediately.""" + + def listener(event): + """Mock listener.""" + + with pytest.raises(HomeAssistantError): + hass.bus.async_listen("test", listener, run_immediately=True) + + async def test_eventbus_unsubscribe_listener(hass: HomeAssistant) -> None: """Test unsubscribe listener from returned function.""" calls = [] @@ -2498,3 +2508,61 @@ async def test_get_release_channel(version: str, release_channel: str) -> None: """Test if release channel detection works from Home Assistant version number.""" with patch("homeassistant.core.__version__", f"{version}"): assert get_release_channel() == release_channel + + +def test_is_callback_check_partial(): + """Test is_callback_check_partial matches HassJob.""" + + @ha.callback + def callback_func(): + pass + + def not_callback_func(): + pass + + assert ha.is_callback(callback_func) + assert HassJob(callback_func).job_type == ha.HassJobType.Callback + assert ha.is_callback_check_partial(functools.partial(callback_func)) + assert HassJob(functools.partial(callback_func)).job_type == ha.HassJobType.Callback + assert ha.is_callback_check_partial( + functools.partial(functools.partial(callback_func)) + ) + assert HassJob(functools.partial(functools.partial(callback_func))).job_type == ( + ha.HassJobType.Callback + ) + assert not ha.is_callback_check_partial(not_callback_func) + assert HassJob(not_callback_func).job_type == ha.HassJobType.Executor + assert not ha.is_callback_check_partial(functools.partial(not_callback_func)) + assert HassJob(functools.partial(not_callback_func)).job_type == ( + ha.HassJobType.Executor + ) + + # We check the inner function, not the outer one + assert not ha.is_callback_check_partial( + ha.callback(functools.partial(not_callback_func)) + ) + assert HassJob(ha.callback(functools.partial(not_callback_func))).job_type == ( + ha.HassJobType.Executor + ) + + +def test_hassjob_passing_job_type(): + """Test passing the job type to HassJob when we already know it.""" + + @ha.callback + def callback_func(): + pass + + def not_callback_func(): + pass + + assert ( + HassJob(callback_func, job_type=ha.HassJobType.Callback).job_type + == ha.HassJobType.Callback + ) + + # We should trust the job_type passed in + assert ( + HassJob(not_callback_func, job_type=ha.HassJobType.Callback).job_type + == ha.HassJobType.Callback + ) diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 4fa10b92706..fd01beed9ab 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -31,9 +31,7 @@ async def test_requirement_installed_in_venv(hass: HomeAssistant) -> None: "homeassistant.util.package.is_virtual_env", return_value=True ), patch("homeassistant.util.package.is_docker_env", return_value=False), patch( "homeassistant.util.package.install_package", return_value=True - ) as mock_install, patch.dict( - os.environ, env_without_wheel_links(), clear=True - ): + ) as mock_install, patch.dict(os.environ, env_without_wheel_links(), clear=True): hass.config.skip_pip = False mock_integration(hass, MockModule("comp", requirements=["package==0.0.1"])) assert await setup.async_setup_component(hass, "comp", {}) @@ -51,9 +49,7 @@ async def test_requirement_installed_in_deps(hass: HomeAssistant) -> None: "homeassistant.util.package.is_virtual_env", return_value=False ), patch("homeassistant.util.package.is_docker_env", return_value=False), patch( "homeassistant.util.package.install_package", return_value=True - ) as mock_install, patch.dict( - os.environ, env_without_wheel_links(), clear=True - ): + ) as mock_install, patch.dict(os.environ, env_without_wheel_links(), clear=True): hass.config.skip_pip = False mock_integration(hass, MockModule("comp", requirements=["package==0.0.1"])) assert await setup.async_setup_component(hass, "comp", {}) @@ -369,7 +365,7 @@ async def test_install_with_wheels_index(hass: HomeAssistant) -> None: ), patch("homeassistant.util.package.install_package") as mock_inst, patch.dict( os.environ, {"WHEELS_LINKS": "https://wheels.hass.io/test"} ), patch( - "os.path.dirname" + "os.path.dirname", ) as mock_dir: mock_dir.return_value = "ha_package_path" assert await setup.async_setup_component(hass, "comp", {}) @@ -391,9 +387,7 @@ async def test_install_on_docker(hass: HomeAssistant) -> None: "homeassistant.util.package.is_docker_env", return_value=True ), patch("homeassistant.util.package.install_package") as mock_inst, patch( "os.path.dirname" - ) as mock_dir, patch.dict( - os.environ, env_without_wheel_links(), clear=True - ): + ) as mock_dir, patch.dict(os.environ, env_without_wheel_links(), clear=True): mock_dir.return_value = "ha_package_path" assert await setup.async_setup_component(hass, "comp", {}) assert "comp" in hass.config.components diff --git a/tests/test_runner.py b/tests/test_runner.py index 5fe5c2881ff..3b06e3b64dc 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -75,7 +75,7 @@ def test_run_executor_shutdown_throws( "homeassistant.runner.InterruptibleThreadPoolExecutor.shutdown", side_effect=RuntimeError, ) as mock_shutdown, patch( - "homeassistant.core.HomeAssistant.async_run" + "homeassistant.core.HomeAssistant.async_run", ) as mock_run: runner.run(default_config) diff --git a/tests/test_setup.py b/tests/test_setup.py index eb4c645ecb1..00bb3fa2a2d 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -23,8 +23,8 @@ from .common import ( MockModule, MockPlatform, assert_setup_component, - mock_entity_platform, mock_integration, + mock_platform, ) @@ -90,9 +90,9 @@ async def test_validate_platform_config( hass, MockModule("platform_conf", platform_schema_base=platform_schema_base), ) - mock_entity_platform( + mock_platform( hass, - "platform_conf.whatever", + "whatever.platform_conf", MockPlatform(platform_schema=platform_schema), ) @@ -156,9 +156,9 @@ async def test_validate_platform_config_2( ), ) - mock_entity_platform( + mock_platform( hass, - "platform_conf.whatever", + "whatever.platform_conf", MockPlatform("whatever", platform_schema=platform_schema), ) @@ -185,9 +185,9 @@ async def test_validate_platform_config_3( hass, MockModule("platform_conf", platform_schema=component_schema) ) - mock_entity_platform( + mock_platform( hass, - "platform_conf.whatever", + "whatever.platform_conf", MockPlatform("whatever", platform_schema=platform_schema), ) @@ -213,9 +213,9 @@ async def test_validate_platform_config_4(hass: HomeAssistant) -> None: MockModule("platform_conf", platform_schema_base=component_schema), ) - mock_entity_platform( + mock_platform( hass, - "platform_conf.whatever", + "whatever.platform_conf", MockPlatform(platform_schema=platform_schema), ) @@ -350,7 +350,7 @@ async def test_component_setup_with_validation_and_dependency( MockModule("platform_a", setup=config_check_setup, dependencies=["comp_a"]), ) - mock_entity_platform(hass, "switch.platform_a", platform) + mock_platform(hass, "platform_a.switch", platform) await setup.async_setup_component( hass, @@ -367,13 +367,15 @@ async def test_platform_specific_config_validation(hass: HomeAssistant) -> None: mock_setup = Mock(spec_set=True) - mock_entity_platform( + mock_platform( hass, - "switch.platform_a", + "platform_a.switch", MockPlatform(platform_schema=platform_schema, setup_platform=mock_setup), ) - with assert_setup_component(0, "switch"): + with assert_setup_component(0, "switch"), patch( + "homeassistant.setup.async_notify_setup_error" + ) as mock_notify: assert await setup.async_setup_component( hass, "switch", @@ -381,11 +383,14 @@ async def test_platform_specific_config_validation(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() assert mock_setup.call_count == 0 + assert len(mock_notify.mock_calls) == 1 hass.data.pop(setup.DATA_SETUP) hass.config.components.remove("switch") - with assert_setup_component(0): + with assert_setup_component(0), patch( + "homeassistant.setup.async_notify_setup_error" + ) as mock_notify: assert await setup.async_setup_component( hass, "switch", @@ -399,11 +404,14 @@ async def test_platform_specific_config_validation(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() assert mock_setup.call_count == 0 + assert len(mock_notify.mock_calls) == 1 hass.data.pop(setup.DATA_SETUP) hass.config.components.remove("switch") - with assert_setup_component(1, "switch"): + with assert_setup_component(1, "switch"), patch( + "homeassistant.setup.async_notify_setup_error" + ) as mock_notify: assert await setup.async_setup_component( hass, "switch", @@ -411,6 +419,7 @@ async def test_platform_specific_config_validation(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() assert mock_setup.call_count == 1 + assert len(mock_notify.mock_calls) == 0 async def test_disable_component_if_invalid_return(hass: HomeAssistant) -> None: @@ -523,7 +532,7 @@ async def test_platform_error_slow_setup( result = await setup.async_setup_component(hass, "test_component1", {}) assert len(called) == 1 assert not result - assert "test_component1 is taking longer than 0.1 seconds" in caplog.text + assert "'test_component1' is taking longer than 0.1 seconds" in caplog.text async def test_when_setup_already_loaded(hass: HomeAssistant) -> None: @@ -618,7 +627,7 @@ async def test_parallel_entry_setup(hass: HomeAssistant, mock_handlers) -> None: async_setup_entry=mock_async_setup_entry, ), ) - mock_entity_platform(hass, "config_flow.comp", None) + mock_platform(hass, "comp.config_flow", None) await setup.async_setup_component(hass, "comp", {}) assert calls == [1, 2, 1, 2] @@ -653,7 +662,7 @@ async def test_integration_logs_is_custom( ): result = await setup.async_setup_component(hass, "test_component1", {}) assert not result - assert "Setup failed for custom integration test_component1: Boom" in caplog.text + assert "Setup failed for custom integration 'test_component1': Boom" in caplog.text async def test_async_get_loaded_integrations(hass: HomeAssistant) -> None: @@ -735,7 +744,7 @@ async def test_setup_config_entry_from_yaml( ) -> None: """Test attempting to setup an integration which only supports config_entries.""" expected_warning = ( - "The test_integration_only_entry integration does not support YAML setup, " + "The 'test_integration_only_entry' integration does not support YAML setup, " "please remove it from your configuration" ) diff --git a/tests/test_util/__init__.py b/tests/test_util/__init__.py index b8499675ea2..fe2c2c640e5 100644 --- a/tests/test_util/__init__.py +++ b/tests/test_util/__init__.py @@ -1 +1,35 @@ -"""Tests for the test utilities.""" +"""Test utilities.""" +from collections.abc import Awaitable, Callable + +from aiohttp.web import Application, Request, StreamResponse, middleware + + +def mock_real_ip(app: Application) -> Callable[[str], None]: + """Inject middleware to mock real IP. + + Returns a function to set the real IP. + """ + ip_to_mock: str | None = None + + def set_ip_to_mock(value: str): + nonlocal ip_to_mock + ip_to_mock = value + + @middleware + async def mock_real_ip( + request: Request, handler: Callable[[Request], Awaitable[StreamResponse]] + ) -> StreamResponse: + """Mock Real IP middleware.""" + nonlocal ip_to_mock + + request = request.clone(remote=ip_to_mock) + + return await handler(request) + + async def real_ip_startup(app): + """Startup of real ip.""" + app.middlewares.insert(0, mock_real_ip) + + app.on_startup.append(real_ip_startup) + + return set_ip_to_mock diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index 356240dc37a..4f2518253ff 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -7,7 +7,11 @@ from unittest import mock from urllib.parse import parse_qs from aiohttp import ClientSession -from aiohttp.client_exceptions import ClientError, ClientResponseError +from aiohttp.client_exceptions import ( + ClientConnectionError, + ClientError, + ClientResponseError, +) from aiohttp.streams import StreamReader from multidict import CIMultiDict from yarl import URL @@ -53,6 +57,7 @@ class AiohttpClientMocker: exc=None, cookies=None, side_effect=None, + closing=None, ): """Mock a request.""" if not isinstance(url, RETYPE): @@ -72,6 +77,7 @@ class AiohttpClientMocker: exc=exc, headers=headers, side_effect=side_effect, + closing=closing, ) ) @@ -165,6 +171,7 @@ class AiohttpClientMockResponse: exc=None, headers=None, side_effect=None, + closing=None, ): """Initialize a fake response.""" if json is not None: @@ -178,9 +185,10 @@ class AiohttpClientMockResponse: self.method = method self._url = url self.status = status - self.response = response + self._response = response self.exc = exc self.side_effect = side_effect + self.closing = closing self._headers = CIMultiDict(headers or {}) self._cookies = {} @@ -272,6 +280,19 @@ class AiohttpClientMockResponse: def close(self): """Mock close.""" + async def wait_for_close(self): + """Wait until all requests are done. + + Do nothing as we are mocking. + """ + + @property + def response(self): + """Property method to expose the response to other read methods.""" + if self.closing: + raise ClientConnectionError("Connection closed") + return self._response + @contextmanager def mock_aiohttp_client(): diff --git a/tests/testing_config/custom_components/test/fan.py b/tests/testing_config/custom_components/test/fan.py new file mode 100644 index 00000000000..133f372f4fa --- /dev/null +++ b/tests/testing_config/custom_components/test/fan.py @@ -0,0 +1,64 @@ +"""Provide a mock fan platform. + +Call init before using it in your tests to ensure clean test data. +""" +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from tests.common import MockEntity + +ENTITIES = {} + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + + ENTITIES = ( + {} + if empty + else { + "support_preset_mode": MockFan( + name="Support fan with preset_mode support", + supported_features=FanEntityFeature.PRESET_MODE, + unique_id="unique_support_preset_mode", + preset_modes=["auto", "eco"], + ) + } + ) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities_callback: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +): + """Return mock entities.""" + async_add_entities_callback(list(ENTITIES.values())) + + +class MockFan(MockEntity, FanEntity): + """Mock Fan class.""" + + @property + def preset_mode(self) -> str | None: + """Return preset mode.""" + return self._handle("preset_mode") + + @property + def preset_modes(self) -> list[str] | None: + """Return preset mode.""" + return self._handle("preset_modes") + + @property + def supported_features(self): + """Return the class of this fan.""" + return self._handle("supported_features") + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set preset mode.""" + self._attr_preset_mode = preset_mode + await self.async_update_ha_state() diff --git a/tests/testing_config/custom_components/test/weather.py b/tests/testing_config/custom_components/test/weather.py index 633a5e4c389..5afb6001b8a 100644 --- a/tests/testing_config/custom_components/test/weather.py +++ b/tests/testing_config/custom_components/test/weather.py @@ -23,6 +23,7 @@ from homeassistant.components.weather import ( Forecast, WeatherEntity, ) +from homeassistant.core import HomeAssistant from tests.common import MockEntity @@ -36,7 +37,7 @@ def init(empty=False): async def async_setup_platform( - hass, config, async_add_entities_callback, discovery_info=None + hass: HomeAssistant, config, async_add_entities_callback, discovery_info=None ): """Return mock entities.""" async_add_entities_callback(ENTITIES) @@ -135,79 +136,10 @@ class MockWeather(MockEntity, WeatherEntity): """Return the current condition.""" return self._handle("condition") - -class MockWeatherCompat(MockEntity, WeatherEntity): - """Mock weather class for backwards compatibility check.""" - @property - def temperature(self) -> float | None: - """Return the platform temperature.""" - return self._handle("temperature") - - @property - def temperature_unit(self) -> str | None: - """Return the unit of measurement for temperature.""" - return self._handle("temperature_unit") - - @property - def pressure(self) -> float | None: - """Return the pressure.""" - return self._handle("pressure") - - @property - def pressure_unit(self) -> str | None: - """Return the unit of measurement for pressure.""" - return self._handle("pressure_unit") - - @property - def humidity(self) -> float | None: - """Return the humidity.""" - return self._handle("humidity") - - @property - def wind_speed(self) -> float | None: - """Return the wind speed.""" - return self._handle("wind_speed") - - @property - def wind_speed_unit(self) -> str | None: - """Return the unit of measurement for wind speed.""" - return self._handle("wind_speed_unit") - - @property - def wind_bearing(self) -> float | str | None: - """Return the wind bearing.""" - return self._handle("wind_bearing") - - @property - def ozone(self) -> float | None: - """Return the ozone level.""" - return self._handle("ozone") - - @property - def visibility(self) -> float | None: - """Return the visibility.""" - return self._handle("visibility") - - @property - def visibility_unit(self) -> str | None: - """Return the unit of measurement for visibility.""" - return self._handle("visibility_unit") - - @property - def forecast(self) -> list[Forecast] | None: - """Return the forecast.""" - return self._handle("forecast") - - @property - def precipitation_unit(self) -> str | None: - """Return the unit of measurement for accumulated precipitation.""" - return self._handle("precipitation_unit") - - @property - def condition(self) -> str | None: - """Return the current condition.""" - return self._handle("condition") + def precision(self) -> float: + """Return the precision of the temperature.""" + return self._handle("precision") class MockWeatherMockForecast(MockWeather): diff --git a/tests/testing_config/custom_components/test_weather/__init__.py b/tests/testing_config/custom_components/test_weather/__init__.py deleted file mode 100644 index ddec081ed8b..00000000000 --- a/tests/testing_config/custom_components/test_weather/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""An integration with Weather platform.""" diff --git a/tests/testing_config/custom_components/test_weather/manifest.json b/tests/testing_config/custom_components/test_weather/manifest.json deleted file mode 100644 index d1238659b41..00000000000 --- a/tests/testing_config/custom_components/test_weather/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "test_weather", - "name": "Test Weather", - "documentation": "http://example.com", - "requirements": [], - "dependencies": [], - "codeowners": [], - "version": "1.2.3" -} diff --git a/tests/testing_config/custom_components/test_weather/weather.py b/tests/testing_config/custom_components/test_weather/weather.py deleted file mode 100644 index 68d9ccab712..00000000000 --- a/tests/testing_config/custom_components/test_weather/weather.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Provide a mock weather platform. - -Call init before using it in your tests to ensure clean test data. -""" -from __future__ import annotations - -from typing import Any - -from homeassistant.components.weather import ( - ATTR_FORECAST_CLOUD_COVERAGE, - ATTR_FORECAST_HUMIDITY, - ATTR_FORECAST_IS_DAYTIME, - ATTR_FORECAST_NATIVE_APPARENT_TEMP, - ATTR_FORECAST_NATIVE_DEW_POINT, - ATTR_FORECAST_NATIVE_PRECIPITATION, - ATTR_FORECAST_NATIVE_PRESSURE, - ATTR_FORECAST_NATIVE_TEMP, - ATTR_FORECAST_NATIVE_TEMP_LOW, - ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, - ATTR_FORECAST_NATIVE_WIND_SPEED, - ATTR_FORECAST_UV_INDEX, - ATTR_FORECAST_WIND_BEARING, - Forecast, - WeatherEntity, -) - -from tests.common import MockEntity - -ENTITIES = [] - - -def init(empty=False): - """Initialize the platform with entities.""" - global ENTITIES - ENTITIES = [] if empty else [MockWeatherMockForecast()] - - -async def async_setup_platform( - hass, config, async_add_entities_callback, discovery_info=None -): - """Return mock entities.""" - async_add_entities_callback(ENTITIES) - - -class MockWeatherMockForecast(MockEntity, WeatherEntity): - """Mock weather class.""" - - def __init__(self, **values: Any) -> None: - """Initialize.""" - super().__init__(**values) - self.forecast_list: list[Forecast] | None = [ - { - ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, - ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, - ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, - ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, - ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage, - ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, - ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed, - ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, - ATTR_FORECAST_WIND_BEARING: self.wind_bearing, - ATTR_FORECAST_UV_INDEX: self.uv_index, - ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( - "native_precipitation" - ), - ATTR_FORECAST_HUMIDITY: self.humidity, - } - ] - - @property - def forecast(self) -> list[Forecast] | None: - """Return the forecast.""" - return self.forecast_list - - async def async_forecast_daily(self) -> list[Forecast] | None: - """Return the forecast_daily.""" - return self.forecast_list - - async def async_forecast_twice_daily(self) -> list[Forecast] | None: - """Return the forecast_twice_daily.""" - return [ - { - ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, - ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, - ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, - ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, - ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage, - ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, - ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed, - ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, - ATTR_FORECAST_WIND_BEARING: self.wind_bearing, - ATTR_FORECAST_UV_INDEX: self.uv_index, - ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( - "native_precipitation" - ), - ATTR_FORECAST_HUMIDITY: self.humidity, - ATTR_FORECAST_IS_DAYTIME: self._values.get("is_daytime"), - } - ] - - async def async_forecast_hourly(self) -> list[Forecast] | None: - """Return the forecast_hourly.""" - return [ - { - ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, - ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, - ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, - ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, - ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage, - ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, - ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed, - ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, - ATTR_FORECAST_WIND_BEARING: self.wind_bearing, - ATTR_FORECAST_UV_INDEX: self.uv_index, - ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( - "native_precipitation" - ), - ATTR_FORECAST_HUMIDITY: self.humidity, - } - ] - - @property - def native_temperature(self) -> float | None: - """Return the platform temperature.""" - return self._handle("native_temperature") - - @property - def native_apparent_temperature(self) -> float | None: - """Return the platform apparent temperature.""" - return self._handle("native_apparent_temperature") - - @property - def native_dew_point(self) -> float | None: - """Return the platform dewpoint temperature.""" - return self._handle("native_dew_point") - - @property - def native_temperature_unit(self) -> str | None: - """Return the unit of measurement for temperature.""" - return self._handle("native_temperature_unit") - - @property - def native_pressure(self) -> float | None: - """Return the pressure.""" - return self._handle("native_pressure") - - @property - def native_pressure_unit(self) -> str | None: - """Return the unit of measurement for pressure.""" - return self._handle("native_pressure_unit") - - @property - def humidity(self) -> float | None: - """Return the humidity.""" - return self._handle("humidity") - - @property - def native_wind_gust_speed(self) -> float | None: - """Return the wind speed.""" - return self._handle("native_wind_gust_speed") - - @property - def native_wind_speed(self) -> float | None: - """Return the wind speed.""" - return self._handle("native_wind_speed") - - @property - def native_wind_speed_unit(self) -> str | None: - """Return the unit of measurement for wind speed.""" - return self._handle("native_wind_speed_unit") - - @property - def wind_bearing(self) -> float | str | None: - """Return the wind bearing.""" - return self._handle("wind_bearing") - - @property - def ozone(self) -> float | None: - """Return the ozone level.""" - return self._handle("ozone") - - @property - def cloud_coverage(self) -> float | None: - """Return the cloud coverage in %.""" - return self._handle("cloud_coverage") - - @property - def uv_index(self) -> float | None: - """Return the UV index.""" - return self._handle("uv_index") - - @property - def native_visibility(self) -> float | None: - """Return the visibility.""" - return self._handle("native_visibility") - - @property - def native_visibility_unit(self) -> str | None: - """Return the unit of measurement for visibility.""" - return self._handle("native_visibility_unit") - - @property - def native_precipitation_unit(self) -> str | None: - """Return the native unit of measurement for accumulated precipitation.""" - return self._handle("native_precipitation_unit") - - @property - def condition(self) -> str | None: - """Return the current condition.""" - return self._handle("condition") diff --git a/tests/util/test_aiohttp.py b/tests/util/test_aiohttp.py index bfdc3c3e949..ada0269ac0e 100644 --- a/tests/util/test_aiohttp.py +++ b/tests/util/test_aiohttp.py @@ -1,5 +1,4 @@ """Test aiohttp request helper.""" -import sys from aiohttp import web @@ -50,22 +49,11 @@ def test_serialize_text() -> None: def test_serialize_body_str() -> None: """Test serializing a response with a str as body.""" response = web.Response(status=201, body="Hello") - # TODO: Remove version check with aiohttp 3.9.0 - if sys.version_info >= (3, 12): - assert aiohttp.serialize_response(response) == { - "status": 201, - "body": "Hello", - "headers": {"Content-Type": "text/plain; charset=utf-8"}, - } - else: - assert aiohttp.serialize_response(response) == { - "status": 201, - "body": "Hello", - "headers": { - "Content-Length": "5", - "Content-Type": "text/plain; charset=utf-8", - }, - } + assert aiohttp.serialize_response(response) == { + "status": 201, + "body": "Hello", + "headers": {"Content-Type": "text/plain; charset=utf-8"}, + } def test_serialize_body_None() -> None: diff --git a/tests/util/test_executor.py b/tests/util/test_executor.py index 763efa494e7..076864c65c4 100644 --- a/tests/util/test_executor.py +++ b/tests/util/test_executor.py @@ -77,19 +77,17 @@ async def test_executor_shutdown_does_not_log_shutdown_on_first_attempt( async def test_overall_timeout_reached(caplog: pytest.LogCaptureFixture) -> None: """Test that shutdown moves on when the overall timeout is reached.""" - iexecutor = InterruptibleThreadPoolExecutor() - def _loop_sleep_in_executor(): time.sleep(1) - for _ in range(6): - iexecutor.submit(_loop_sleep_in_executor) - - start = time.monotonic() with patch.object(executor, "EXECUTOR_SHUTDOWN_TIMEOUT", 0.5): + iexecutor = InterruptibleThreadPoolExecutor() + for _ in range(6): + iexecutor.submit(_loop_sleep_in_executor) + start = time.monotonic() iexecutor.shutdown() - finish = time.monotonic() + finish = time.monotonic() - assert finish - start < 1.2 + assert finish - start < 1.3 iexecutor.shutdown() diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 4f60c5836b5..c4e5c58e235 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -1,13 +1,15 @@ """Test Home Assistant yaml loader.""" +from collections.abc import Generator import importlib import io import os import pathlib from typing import Any import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest +import voluptuous as vol import yaml as pyyaml from homeassistant.config import YAML_CONFIG_FILE, load_yaml_config_file @@ -57,7 +59,7 @@ def test_simple_list(try_both_loaders) -> None: """Test simple list.""" conf = "config:\n - simple\n - list" with io.StringIO(conf) as file: - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + doc = yaml_loader.parse_yaml(file) assert doc["config"] == ["simple", "list"] @@ -65,12 +67,12 @@ def test_simple_dict(try_both_loaders) -> None: """Test simple dict.""" conf = "key: value" with io.StringIO(conf) as file: - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + doc = yaml_loader.parse_yaml(file) assert doc["key"] == "value" @pytest.mark.parametrize("hass_config_yaml", ["message:\n {{ states.state }}"]) -def test_unhashable_key(mock_hass_config_yaml: None) -> None: +def test_unhashable_key(try_both_loaders, mock_hass_config_yaml: None) -> None: """Test an unhashable key.""" with pytest.raises(HomeAssistantError): load_yaml_config_file(YAML_CONFIG_FILE) @@ -88,7 +90,7 @@ def test_environment_variable(try_both_loaders) -> None: os.environ["PASSWORD"] = "secret_password" conf = "password: !env_var PASSWORD" with io.StringIO(conf) as file: - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + doc = yaml_loader.parse_yaml(file) assert doc["password"] == "secret_password" del os.environ["PASSWORD"] @@ -97,7 +99,7 @@ def test_environment_variable_default(try_both_loaders) -> None: """Test config file with default value for environment variable.""" conf = "password: !env_var PASSWORD secret_password" with io.StringIO(conf) as file: - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + doc = yaml_loader.parse_yaml(file) assert doc["password"] == "secret_password" @@ -105,12 +107,16 @@ def test_invalid_environment_variable(try_both_loaders) -> None: """Test config file with no environment variable sat.""" conf = "password: !env_var PASSWORD" with pytest.raises(HomeAssistantError), io.StringIO(conf) as file: - yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + yaml_loader.parse_yaml(file) @pytest.mark.parametrize( ("hass_config_yaml_files", "value"), - [({"test.yaml": "value"}, "value"), ({"test.yaml": None}, {})], + [ + ({"test.yaml": "value"}, "value"), + ({"test.yaml": None}, {}), + ({"test.yaml": "123"}, 123), + ], ) def test_include_yaml( try_both_loaders, mock_hass_config_yaml: None, value: Any @@ -118,24 +124,28 @@ def test_include_yaml( """Test include yaml.""" conf = "key: !include test.yaml" with io.StringIO(conf) as file: - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + doc = yaml_loader.parse_yaml(file) assert doc["key"] == value @patch("homeassistant.util.yaml.loader.os.walk") @pytest.mark.parametrize( - "hass_config_yaml_files", [{"/test/one.yaml": "one", "/test/two.yaml": "two"}] + ("hass_config_yaml_files", "value"), + [ + ({"/test/one.yaml": "one", "/test/two.yaml": "two"}, ["one", "two"]), + ({"/test/one.yaml": "1", "/test/two.yaml": "2"}, [1, 2]), + ], ) def test_include_dir_list( - mock_walk, try_both_loaders, mock_hass_config_yaml: None + mock_walk, try_both_loaders, mock_hass_config_yaml: None, value: Any ) -> None: """Test include dir list yaml.""" mock_walk.return_value = [["/test", [], ["two.yaml", "one.yaml"]]] conf = "key: !include_dir_list /test" with io.StringIO(conf) as file: - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) - assert doc["key"] == sorted(["one", "two"]) + doc = yaml_loader.parse_yaml(file) + assert sorted(doc["key"]) == sorted(value) @patch("homeassistant.util.yaml.loader.os.walk") @@ -162,7 +172,7 @@ def test_include_dir_list_recursive( conf = "key: !include_dir_list /test" with io.StringIO(conf) as file: assert ".ignore" in mock_walk.return_value[0][1], "Expecting .ignore in here" - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + doc = yaml_loader.parse_yaml(file) assert "tmp2" in mock_walk.return_value[0][1] assert ".ignore" not in mock_walk.return_value[0][1] assert sorted(doc["key"]) == sorted(["zero", "one", "two"]) @@ -170,11 +180,20 @@ def test_include_dir_list_recursive( @patch("homeassistant.util.yaml.loader.os.walk") @pytest.mark.parametrize( - "hass_config_yaml_files", - [{"/test/first.yaml": "one", "/test/second.yaml": "two"}], + ("hass_config_yaml_files", "value"), + [ + ( + {"/test/first.yaml": "one", "/test/second.yaml": "two"}, + {"first": "one", "second": "two"}, + ), + ( + {"/test/first.yaml": "1", "/test/second.yaml": "2"}, + {"first": 1, "second": 2}, + ), + ], ) def test_include_dir_named( - mock_walk, try_both_loaders, mock_hass_config_yaml: None + mock_walk, try_both_loaders, mock_hass_config_yaml: None, value: Any ) -> None: """Test include dir named yaml.""" mock_walk.return_value = [ @@ -182,10 +201,9 @@ def test_include_dir_named( ] conf = "key: !include_dir_named /test" - correct = {"first": "one", "second": "two"} with io.StringIO(conf) as file: - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) - assert doc["key"] == correct + doc = yaml_loader.parse_yaml(file) + assert doc["key"] == value @patch("homeassistant.util.yaml.loader.os.walk") @@ -213,7 +231,7 @@ def test_include_dir_named_recursive( correct = {"first": "one", "second": "two", "third": "three"} with io.StringIO(conf) as file: assert ".ignore" in mock_walk.return_value[0][1], "Expecting .ignore in here" - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + doc = yaml_loader.parse_yaml(file) assert "tmp2" in mock_walk.return_value[0][1] assert ".ignore" not in mock_walk.return_value[0][1] assert doc["key"] == correct @@ -221,19 +239,28 @@ def test_include_dir_named_recursive( @patch("homeassistant.util.yaml.loader.os.walk") @pytest.mark.parametrize( - "hass_config_yaml_files", - [{"/test/first.yaml": "- one", "/test/second.yaml": "- two\n- three"}], + ("hass_config_yaml_files", "value"), + [ + ( + {"/test/first.yaml": "- one", "/test/second.yaml": "- two\n- three"}, + ["one", "two", "three"], + ), + ( + {"/test/first.yaml": "- 1", "/test/second.yaml": "- 2\n- 3"}, + [1, 2, 3], + ), + ], ) def test_include_dir_merge_list( - mock_walk, try_both_loaders, mock_hass_config_yaml: None + mock_walk, try_both_loaders, mock_hass_config_yaml: None, value: Any ) -> None: """Test include dir merge list yaml.""" mock_walk.return_value = [["/test", [], ["first.yaml", "second.yaml"]]] conf = "key: !include_dir_merge_list /test" with io.StringIO(conf) as file: - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) - assert sorted(doc["key"]) == sorted(["one", "two", "three"]) + doc = yaml_loader.parse_yaml(file) + assert sorted(doc["key"]) == sorted(value) @patch("homeassistant.util.yaml.loader.os.walk") @@ -260,7 +287,7 @@ def test_include_dir_merge_list_recursive( conf = "key: !include_dir_merge_list /test" with io.StringIO(conf) as file: assert ".ignore" in mock_walk.return_value[0][1], "Expecting .ignore in here" - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + doc = yaml_loader.parse_yaml(file) assert "tmp2" in mock_walk.return_value[0][1] assert ".ignore" not in mock_walk.return_value[0][1] assert sorted(doc["key"]) == sorted(["one", "two", "three", "four"]) @@ -268,24 +295,34 @@ def test_include_dir_merge_list_recursive( @patch("homeassistant.util.yaml.loader.os.walk") @pytest.mark.parametrize( - "hass_config_yaml_files", + ("hass_config_yaml_files", "value"), [ - { - "/test/first.yaml": "key1: one", - "/test/second.yaml": "key2: two\nkey3: three", - } + ( + { + "/test/first.yaml": "key1: one", + "/test/second.yaml": "key2: two\nkey3: three", + }, + {"key1": "one", "key2": "two", "key3": "three"}, + ), + ( + { + "/test/first.yaml": "key1: 1", + "/test/second.yaml": "key2: 2\nkey3: 3", + }, + {"key1": 1, "key2": 2, "key3": 3}, + ), ], ) def test_include_dir_merge_named( - mock_walk, try_both_loaders, mock_hass_config_yaml: None + mock_walk, try_both_loaders, mock_hass_config_yaml: None, value: Any ) -> None: """Test include dir merge named yaml.""" mock_walk.return_value = [["/test", [], ["first.yaml", "second.yaml"]]] conf = "key: !include_dir_merge_named /test" with io.StringIO(conf) as file: - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) - assert doc["key"] == {"key1": "one", "key2": "two", "key3": "three"} + doc = yaml_loader.parse_yaml(file) + assert doc["key"] == value @patch("homeassistant.util.yaml.loader.os.walk") @@ -312,7 +349,7 @@ def test_include_dir_merge_named_recursive( conf = "key: !include_dir_merge_named /test" with io.StringIO(conf) as file: assert ".ignore" in mock_walk.return_value[0][1], "Expecting .ignore in here" - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + doc = yaml_loader.parse_yaml(file) assert "tmp2" in mock_walk.return_value[0][1] assert ".ignore" not in mock_walk.return_value[0][1] assert doc["key"] == { @@ -538,7 +575,7 @@ def test_c_loader_is_available_in_ci() -> None: assert yaml.loader.HAS_C_LOADER is True -async def test_loading_actual_file_with_syntax( +async def test_loading_actual_file_with_syntax_error( hass: HomeAssistant, try_both_loaders ) -> None: """Test loading a real file with syntax errors.""" @@ -547,3 +584,105 @@ async def test_loading_actual_file_with_syntax( "fixtures", "bad.yaml.txt" ) await hass.async_add_executor_job(load_yaml_config_file, fixture_path) + + +@pytest.fixture +def mock_integration_frame() -> Generator[Mock, None, None]: + """Mock as if we're calling code from inside an integration.""" + correct_frame = Mock( + filename="/home/paulus/homeassistant/components/hue/light.py", + lineno="23", + line="self.light.is_on", + ) + with patch( + "homeassistant.helpers.frame.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + correct_frame, + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + yield correct_frame + + +@pytest.mark.parametrize( + ("loader_class", "message"), + [ + (yaml.loader.SafeLoader, "'SafeLoader' instead of 'FastSafeLoader'"), + ( + yaml.loader.SafeLineLoader, + "'SafeLineLoader' instead of 'PythonSafeLoader'", + ), + ], +) +async def test_deprecated_loaders( + hass: HomeAssistant, + mock_integration_frame: Mock, + caplog: pytest.LogCaptureFixture, + loader_class, + message: str, +) -> None: + """Test instantiating the deprecated yaml loaders logs a warning.""" + with pytest.raises(TypeError), patch( + "homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set() + ): + loader_class() + assert (f"Detected that integration 'hue' uses deprecated {message}") in caplog.text + + +def test_string_annotated(try_both_loaders) -> None: + """Test strings are annotated with file + line.""" + conf = ( + "key1: str\n" + "key2:\n" + " blah: blah\n" + "key3:\n" + " - 1\n" + " - 2\n" + " - 3\n" + "key4: yes\n" + "key5: 1\n" + "key6: 1.0\n" + ) + expected_annotations = { + "key1": [("", 1), ("", 1)], + "key2": [("", 2), ("", 3)], + "key3": [("", 4), ("", 5)], + "key4": [("", 8), (None, None)], + "key5": [("", 9), (None, None)], + "key6": [("", 10), (None, None)], + } + with io.StringIO(conf) as file: + doc = yaml_loader.parse_yaml(file) + for key, value in doc.items(): + assert getattr(key, "__config_file__", None) == expected_annotations[key][0][0] + assert getattr(key, "__line__", None) == expected_annotations[key][0][1] + assert ( + getattr(value, "__config_file__", None) == expected_annotations[key][1][0] + ) + assert getattr(value, "__line__", None) == expected_annotations[key][1][1] + + +def test_string_used_as_vol_schema(try_both_loaders) -> None: + """Test the subclassed strings can be used in voluptuous schemas.""" + conf = "wanted_data:\n key_1: value_1\n key_2: value_2\n" + with io.StringIO(conf) as file: + doc = yaml_loader.parse_yaml(file) + + # Test using the subclassed strings in a schema + schema = vol.Schema( + {vol.Required(key): value for key, value in doc["wanted_data"].items()}, + ) + # Test using the subclassed strings when validating a schema + schema(doc["wanted_data"]) + schema({"key_1": "value_1", "key_2": "value_2"}) + with pytest.raises(vol.Invalid): + schema({"key_1": "value_2", "key_2": "value_1"})